fix: update volume tracking and visibility logic

- Marked volumes as user-visible based on specific criteria to prevent redundant or non-useful system volumes from being displayed.
- Enhanced the volume tracking actions to ensure only user-visible volumes are included in tracking and untracking operations.
- Updated the storage overview component to filter and display only user-visible volumes, improving user experience and clarity.
- Refactored related code for better maintainability and readability, ensuring consistent handling of volume visibility across the application.
This commit is contained in:
Jamie Pine
2025-11-18 01:52:33 -08:00
parent ef25390441
commit 5d1aa8aaa3
12 changed files with 234 additions and 144 deletions

View File

@@ -3,6 +3,7 @@
//! This represents volumes in Spacedrive, combining runtime detection capabilities
//! with database tracking and user preferences. Supports local, network, and cloud volumes.
use crate::domain::resource::Identifiable;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -526,6 +527,19 @@ pub enum FileSystem {
Other(String),
}
impl Identifiable for Volume {
fn id(&self) -> Uuid {
self.id
}
fn resource_type() -> &'static str
where
Self: Sized,
{
"volume"
}
}
impl Volume {
/// Create a new tracked volume
pub fn new(

View File

@@ -59,7 +59,7 @@ impl VolumeListQuery {
// Query to calculate unique bytes on this volume:
// 1. Join entries with directory_paths to get full paths
// 2. Filter entries whose paths start with this volume's mount point
// 2. Filter entries whose paths start with this volume's mount point
// 3. Join with content_identity to get content hashes
// 4. Group by content_hash to deduplicate, then sum total_size
let query = r#"
@@ -158,7 +158,11 @@ impl LibraryQuery for VolumeListQuery {
let db = library.db().conn();
// Get tracked volumes from database (includes volumes from ALL devices)
let tracked_volumes = entities::volume::Entity::find().all(db).await?;
// Only include user-visible volumes
let tracked_volumes = entities::volume::Entity::find()
.filter(entities::volume::Column::IsUserVisible.eq(true))
.all(db)
.await?;
// Create a map of tracked volumes by fingerprint
let mut tracked_map: HashMap<String, entities::volume::Model> = tracked_volumes
@@ -209,7 +213,8 @@ impl LibraryQuery for VolumeListQuery {
if matches!(self.filter, VolumeFilter::All) {
let all_volumes = volume_manager.get_all_volumes().await;
for vol in all_volumes {
if !tracked_map.contains_key(&vol.fingerprint.0) {
// Only show user-visible volumes
if !tracked_map.contains_key(&vol.fingerprint.0) && vol.is_user_visible {
volume_items.push(super::output::VolumeItem {
id: vol.id,
name: vol.name.clone(),
@@ -235,9 +240,9 @@ impl LibraryQuery for VolumeListQuery {
// Get all detected volumes from volume manager (current device only)
let all_volumes = volume_manager.get_all_volumes().await;
// Only return volumes that are NOT tracked
// Only return volumes that are NOT tracked and are user-visible
for vol in all_volumes {
if !tracked_map.contains_key(&vol.fingerprint.0) {
if !tracked_map.contains_key(&vol.fingerprint.0) && vol.is_user_visible {
volume_items.push(super::output::VolumeItem {
id: vol.id,
name: vol.name.clone(),

View File

@@ -1,23 +1,14 @@
//! Track volume action
//!
//! This action tracks a volume within a library, allowing Spacedrive to monitor
//! and index files on the volume.
//! Volume track action
use super::output::VolumeTrackOutput;
use super::{VolumeTrackInput, VolumeTrackOutput};
use crate::{
context::CoreContext,
infra::action::{error::ActionError, LibraryAction},
domain::{resource::Identifiable, volume::Volume},
infra::{action::error::ActionError, event::Event},
volume::VolumeFingerprint,
};
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct VolumeTrackInput {
pub fingerprint: VolumeFingerprint,
pub name: Option<String>,
}
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeTrackAction {
@@ -28,67 +19,64 @@ impl VolumeTrackAction {
pub fn new(input: VolumeTrackInput) -> Self {
Self { input }
}
/// Create a volume track action with a name
pub fn with_name(fingerprint: VolumeFingerprint, name: String) -> Self {
Self::new(VolumeTrackInput {
fingerprint,
name: Some(name),
})
}
/// Create a volume track action without a name
pub fn without_name(fingerprint: VolumeFingerprint) -> Self {
Self::new(VolumeTrackInput {
fingerprint,
name: None,
})
}
}
impl LibraryAction for VolumeTrackAction {
crate::register_library_action!(VolumeTrackAction, "volumes.track");
impl crate::infra::action::LibraryAction for VolumeTrackAction {
type Input = VolumeTrackInput;
type Output = VolumeTrackOutput;
fn from_input(input: VolumeTrackInput) -> Result<Self, String> {
fn from_input(input: Self::Input) -> Result<Self, String> {
Ok(VolumeTrackAction::new(input))
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
context: std::sync::Arc<CoreContext>,
library: Arc<crate::library::Library>,
context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
// Check if volume exists
let volume = context
.volume_manager
.get_volume(&self.input.fingerprint)
.await
.ok_or_else(|| ActionError::InvalidInput("Volume not found".to_string()))?;
let fingerprint = VolumeFingerprint::from_string(&self.input.fingerprint)
.map_err(|e| ActionError::Internal(format!("Invalid fingerprint: {}", e)))?;
if !volume.is_mounted {
return Err(ActionError::InvalidInput(
"Cannot track unmounted volume".to_string(),
));
// Track the volume
let tracked_volume = context
.volume_manager
.track_volume(&library, &fingerprint, self.input.display_name.clone())
.await
.map_err(|e| ActionError::Internal(e.to_string()))?;
// Get the volume from volume manager to emit full Volume resource
let volumes = context.volume_manager.get_all_volumes().await;
let volume = volumes
.iter()
.find(|v| v.fingerprint == fingerprint)
.cloned();
// Emit ResourceChanged event
if let Some(mut vol) = volume {
vol.is_tracked = true;
vol.library_id = Some(library.id());
context.events.emit(Event::ResourceChanged {
resource_type: Volume::resource_type().to_string(),
resource: serde_json::to_value(&vol)
.map_err(|e| ActionError::Internal(e.to_string()))?,
metadata: None,
});
}
// Track the volume in the database
let tracked = context
.volume_manager
.track_volume(&library, &self.input.fingerprint, self.input.name.clone())
.await
.map_err(|e| ActionError::InvalidInput(format!("Volume tracking failed: {}", e)))?;
Ok(VolumeTrackOutput::new(
self.input.fingerprint,
tracked.display_name.unwrap_or(volume.name),
))
Ok(VolumeTrackOutput {
volume_id: tracked_volume.uuid,
fingerprint: tracked_volume.fingerprint,
name: tracked_volume
.display_name
.unwrap_or_else(|| "Unnamed".to_string()),
is_online: tracked_volume.is_online,
})
}
fn action_kind(&self) -> &'static str {
"volumes.track"
}
}
// Register action
crate::register_library_action!(VolumeTrackAction, "volumes.track");

View File

@@ -0,0 +1,15 @@
//! Volume track input
use crate::volume::VolumeFingerprint;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct VolumeTrackInput {
/// Fingerprint of the volume to track
pub fingerprint: String,
/// Optional custom display name
pub display_name: Option<String>,
}

View File

@@ -1,4 +1,9 @@
//! Track volume operation
pub mod action;
pub mod input;
pub mod output;
pub use action::VolumeTrackAction;
pub use input::VolumeTrackInput;
pub use output::VolumeTrackOutput;

View File

@@ -1,25 +1,20 @@
//! Volume track operation output types
//! Volume track output
use crate::volume::VolumeFingerprint;
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
/// Output from volume track operation
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct VolumeTrackOutput {
/// The fingerprint of the tracked volume
pub fingerprint: VolumeFingerprint,
/// UUID of the tracked volume
pub volume_id: Uuid,
/// The display name of the tracked volume
pub volume_name: String,
}
/// Fingerprint of the volume
pub fingerprint: String,
impl VolumeTrackOutput {
/// Create new volume track output
pub fn new(fingerprint: VolumeFingerprint, volume_name: String) -> Self {
Self {
fingerprint,
volume_name,
}
}
/// Display name
pub name: String,
/// Whether the volume is currently online
pub is_online: bool,
}

View File

@@ -1,64 +1,70 @@
//! Untrack volume action
//!
//! This action removes volume tracking from a library.
//! Volume untrack action
use super::output::VolumeUntrackOutput;
use super::{VolumeUntrackInput, VolumeUntrackOutput};
use crate::{
context::CoreContext,
infra::action::{error::ActionError, LibraryAction},
volume::VolumeFingerprint,
domain::{resource::Identifiable, volume::Volume},
infra::{action::error::ActionError, db::entities, event::Event},
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct VolumeUntrackInput {
pub fingerprint: VolumeFingerprint,
}
/// Input for untracking a volume
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeUntrackAction {
/// The fingerprint of the volume to untrack
input: VolumeUntrackInput,
}
impl VolumeUntrackAction {
/// Create a new volume untrack action
pub fn new(input: VolumeUntrackInput) -> Self {
Self { input }
}
}
// Implement the unified ActionTrait (following VolumeTrackAction model)
impl LibraryAction for VolumeUntrackAction {
crate::register_library_action!(VolumeUntrackAction, "volumes.untrack");
impl crate::infra::action::LibraryAction for VolumeUntrackAction {
type Input = VolumeUntrackInput;
type Output = VolumeUntrackOutput;
fn from_input(input: VolumeUntrackInput) -> Result<Self, String> {
fn from_input(input: Self::Input) -> Result<Self, String> {
Ok(VolumeUntrackAction::new(input))
}
async fn execute(
self,
library: std::sync::Arc<crate::library::Library>,
context: std::sync::Arc<CoreContext>,
library: Arc<crate::library::Library>,
context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
// Untrack the volume from the database
context
.volume_manager
.untrack_volume(&library, &self.input.fingerprint)
.await
.map_err(|e| ActionError::InvalidInput(format!("Volume untracking failed: {}", e)))?;
let db = library.db().conn();
// Return native output directly
Ok(VolumeUntrackOutput::new(self.input.fingerprint))
// Find the volume in the database
let volume = entities::volume::Entity::find()
.filter(entities::volume::Column::Uuid.eq(self.input.volume_id))
.one(db)
.await
.map_err(|e| ActionError::Internal(e.to_string()))?
.ok_or_else(|| ActionError::Internal("Volume not found".to_string()))?;
// Delete the volume from database
entities::volume::Entity::delete_by_id(volume.id)
.exec(db)
.await
.map_err(|e| ActionError::Internal(e.to_string()))?;
// Emit ResourceDeleted event
context.events.emit(Event::ResourceDeleted {
resource_type: Volume::resource_type().to_string(),
resource_id: self.input.volume_id,
});
Ok(VolumeUntrackOutput {
volume_id: self.input.volume_id,
success: true,
})
}
fn action_kind(&self) -> &'static str {
"volumes.untrack"
}
}
// Register action
crate::register_library_action!(VolumeUntrackAction, "volumes.untrack");

View File

@@ -0,0 +1,12 @@
//! Volume untrack input
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct VolumeUntrackInput {
/// UUID of the volume to untrack
pub volume_id: Uuid,
}

View File

@@ -1,4 +1,9 @@
//! Untrack volume operation
pub mod action;
pub mod input;
pub mod output;
pub use action::VolumeUntrackAction;
pub use input::VolumeUntrackInput;
pub use output::VolumeUntrackOutput;

View File

@@ -1,19 +1,14 @@
//! Volume untrack operation output types
//! Volume untrack output
use crate::volume::VolumeFingerprint;
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
/// Output from volume untrack operation
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct VolumeUntrackOutput {
/// The fingerprint of the untracked volume
pub fingerprint: VolumeFingerprint,
}
/// UUID of the untracked volume
pub volume_id: Uuid,
impl VolumeUntrackOutput {
/// Create new volume untrack output
pub fn new(fingerprint: VolumeFingerprint) -> Self {
Self { fingerprint }
}
/// Whether the operation was successful
pub success: bool,
}

View File

@@ -356,20 +356,19 @@ pub fn containers_to_volumes(
let mount_type = determine_mount_type(&volume_info.role, mount_point);
let volume_type = classify_volume_type(&volume_info.role, mount_point);
// Auto-track eligibility: Primary, UserData, System volumes
// Also track Secondary volumes if they're system mounts (e.g., developer tools)
// Determine if volume should be user-visible
let is_user_visible = should_be_user_visible(mount_point, &volume_info.role, &volume_info.name);
// Auto-track eligibility: Only UserData volume
// Previously we auto-tracked System, Primary, etc., but that created too many overlapping volumes
let auto_track_eligible = matches!(
volume_type,
crate::volume::types::VolumeType::UserData
| crate::volume::types::VolumeType::Primary
| crate::volume::types::VolumeType::System
) || (volume_type
== crate::volume::types::VolumeType::Secondary
&& mount_type == crate::volume::types::MountType::System);
) && is_user_visible;
debug!(
"APFS_CONVERT: Volume '{}' classified as Type={:?}, auto_track_eligible={}",
volume_info.name, volume_type, auto_track_eligible
"APFS_CONVERT: Volume '{}' classified as Type={:?}, user_visible={}, auto_track_eligible={}",
volume_info.name, volume_type, is_user_visible, auto_track_eligible
);
// Get space information (total capacity and available space)
@@ -409,7 +408,7 @@ pub fn containers_to_volumes(
apfs_container: Some(container.clone()),
container_volume_id: Some(volume_info.disk_id.clone()),
path_mappings,
is_user_visible: true,
is_user_visible,
auto_track_eligible,
read_speed_mbps: None,
write_speed_mbps: None,
@@ -494,6 +493,56 @@ fn classify_volume_type(
}
}
/// Determine if a volume should be visible to the user
/// Filters out system volumes that are redundant or not useful for user interaction
fn should_be_user_visible(
mount_point: &PathBuf,
role: &ApfsVolumeRole,
name: &str,
) -> bool {
let mount_str = mount_point.to_string_lossy();
// Hide system utility volumes
match role {
ApfsVolumeRole::Preboot | ApfsVolumeRole::Recovery | ApfsVolumeRole::VM => return false,
_ => {}
}
// Hide specific mount points
if mount_str.starts_with("/System/Volumes/Preboot")
|| mount_str.starts_with("/System/Volumes/VM")
|| mount_str.starts_with("/System/Volumes/Hardware")
|| mount_str.starts_with("/System/Volumes/Update")
|| mount_str.starts_with("/System/Volumes/xarts")
|| mount_str.starts_with("/System/Volumes/iSCPreboot")
{
return false;
}
// Hide iOS Simulator volumes
if mount_str.starts_with("/Library/Developer/CoreSimulator") {
return false;
}
// Hide home autofs mounts
if mount_str.contains("/home") && name.to_lowercase() == "home" {
return false;
}
// Hide snapshot mounts (usually contain @ symbol)
if mount_str.contains("@") {
return false;
}
// Hide the root "/" volume if it's a system volume (prefer showing Data volume instead)
// The Data volume is where actual user files live in modern macOS
if mount_str.as_ref() == "/" && matches!(role, ApfsVolumeRole::System) {
return false;
}
true
}
/// Get volume space information (platform-specific implementation needed)
fn get_volume_space_info(mount_point: &PathBuf) -> VolumeResult<(u64, u64)> {
// This would need platform-specific implementation

View File

@@ -53,11 +53,7 @@ function getVolumeIcon(volumeType: string, name?: string): string {
}
function getDiskTypeLabel(diskType: string): string {
return diskType === "SSD"
? "SSD"
: diskType === "HDD"
? "HDD"
: diskType;
return diskType === "SSD" ? "SSD" : diskType === "HDD" ? "HDD" : diskType;
}
export function StorageOverview() {
@@ -101,14 +97,19 @@ export function StorageOverview() {
const volumes = volumesData?.volumes || [];
const devices = devicesData || [];
// Filter to only show user-visible volumes
const userVisibleVolumes = volumes.filter(
(volume) => volume.is_user_visible !== false,
);
// Group volumes by device - note: VolumeItem doesn't have device_id yet
// So we'll just show all volumes ungrouped for now
// TODO: Backend needs to add device_id to VolumeItem
const volumesByDevice: Record<string, typeof volumes> = {};
const volumesByDevice: Record<string, typeof userVisibleVolumes> = {};
// For now, create a single "All Devices" group
if (volumes.length > 0) {
volumesByDevice["all"] = volumes;
if (userVisibleVolumes.length > 0) {
volumesByDevice["all"] = userVisibleVolumes;
}
return (
@@ -118,9 +119,9 @@ export function StorageOverview() {
Storage Volumes
</h2>
<p className="text-sm text-ink-dull mt-1">
{volumes.length}{" "}
{volumes.length === 1 ? "volume" : "volumes"} across{" "}
{devices.length}{" "}
{userVisibleVolumes.length}{" "}
{userVisibleVolumes.length === 1 ? "volume" : "volumes"}{" "}
across {devices.length}{" "}
{devices.length === 1 ? "device" : "devices"}
</p>
</div>
@@ -145,7 +146,7 @@ export function StorageOverview() {
},
)}
{volumes.length === 0 && (
{userVisibleVolumes.length === 0 && (
<div className="text-center py-12 text-ink-faint">
<HardDrive className="size-12 mx-auto mb-3 opacity-20" />
<p className="text-sm">No volumes detected</p>