Merge pull request #2977 from spacedriveapp/stable-volume-fingerprint

Stable volume fingerprint
This commit is contained in:
Jamie Pine
2026-01-16 14:02:51 -08:00
committed by GitHub
48 changed files with 1241 additions and 380 deletions

View File

@@ -180,16 +180,13 @@ async fn run_interactive_add(ctx: &Context) -> Result<LocationAddInput> {
let volume_choices: Vec<String> = volumes
.volumes
.iter()
.filter_map(|v| {
// Parse mount_point to get service and identifier
v.mount_point.as_ref().map(|mp| {
format!(
"{} ({}) - {}",
v.name,
mp, // Show mount point like "s3://bucket"
v.volume_type
)
})
.map(|v| {
format!(
"{} ({}) - {}",
v.name,
v.mount_point.display(), // Show mount point like "s3://bucket"
v.volume_type
)
})
.collect();
@@ -203,10 +200,7 @@ async fn run_interactive_add(ctx: &Context) -> Result<LocationAddInput> {
// Get the mount point for the selected volume
let selected_volume = &volumes.volumes[volume_idx];
let mount_point = selected_volume
.mount_point
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Selected volume has no mount point"))?;
let mount_point = &selected_volume.mount_point;
// Get cloud path within the volume
let cloud_path = text(
@@ -216,10 +210,11 @@ async fn run_interactive_add(ctx: &Context) -> Result<LocationAddInput> {
.unwrap();
// Construct service-based URI: mount_point + path
let mount_point_str = mount_point.to_string_lossy();
let full_uri = if cloud_path.starts_with('/') {
format!("{}{}", mount_point, cloud_path)
format!("{}{}", mount_point_str, cloud_path)
} else {
format!("{}/{}", mount_point, cloud_path)
format!("{}/{}", mount_point_str, cloud_path)
};
// Parse the URI to create SdPath

View File

@@ -86,9 +86,9 @@ pub async fn run(ctx: &Context, cmd: VolumeCmd) -> Result<()> {
println!(" ID: {}", volume.id);
println!(" Fingerprint: {}", volume.fingerprint);
println!(" Type: {:?}", volume.volume_type);
ttttprintln!(" Mount: {}", volume.mount_point.display());
ttttprintln!(" Mounted: {}", volume.is_mounted);
ttttprintln!(" Tracked: {}", volume.is_tracked);
println!(" Mount: {}", volume.mount_point.display());
println!(" Mounted: {}", volume.is_mounted);
println!(" Tracked: {}", volume.is_tracked);
println!();
}
}

View File

@@ -0,0 +1,74 @@
//! Debug volume detection
//!
//! Run with: cargo run --example debug_volumes
use sd_core::volume::{
detection::detect_volumes,
types::{VolumeDetectionConfig, VolumeType},
};
use uuid::Uuid;
#[tokio::main]
async fn main() {
// Enable debug logging
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_target(true)
.init();
println!("\n=== Volume Detection Debug ===\n");
let device_id = Uuid::new_v4();
let config = VolumeDetectionConfig {
include_system: true,
include_virtual: false,
run_speed_test: false,
refresh_interval_secs: 0,
};
match detect_volumes(device_id, &config).await {
Ok(volumes) => {
println!("Detected {} volumes:\n", volumes.len());
for vol in &volumes {
println!("Volume: {}", vol.name);
println!(" Display name: {}", vol.display_name.as_ref().unwrap_or(&"None".to_string()));
println!(" Mount point: {}", vol.mount_point.display());
println!(" Type: {:?}", vol.volume_type);
println!(" Filesystem: {}", vol.file_system);
println!(" Fingerprint: {} ({})", vol.fingerprint.short_id(), vol.fingerprint.0);
println!(" Is user visible: {}", vol.is_user_visible);
println!(" Auto-track eligible: {}", vol.auto_track_eligible);
println!(" Is tracked: {}", vol.is_tracked);
println!();
}
// Show specifically which volumes are auto-track eligible
let auto_track: Vec<_> = volumes
.iter()
.filter(|v| v.auto_track_eligible)
.collect();
println!("=== Auto-Track Eligible Volumes ({}) ===", auto_track.len());
for vol in auto_track {
println!(" - {} ({})", vol.display_name.as_ref().unwrap_or(&vol.name), vol.mount_point.display());
}
// Show Primary volumes specifically
let primary: Vec<_> = volumes
.iter()
.filter(|v| matches!(v.volume_type, VolumeType::Primary))
.collect();
println!("\n=== Primary Volumes ({}) ===", primary.len());
for vol in primary {
println!(" - {} at {}", vol.name, vol.mount_point.display());
println!(" Auto-track eligible: {}", vol.auto_track_eligible);
println!(" Is user visible: {}", vol.is_user_visible);
}
}
Err(e) => {
eprintln!("Error detecting volumes: {}", e);
}
}
}

View File

@@ -3,86 +3,79 @@
//! Run with: cargo run --example fingerprint_test
use sd_core::domain::volume::VolumeFingerprint;
use std::path::PathBuf;
use uuid::Uuid;
fn main() {
println!("\n=== Volume Fingerprint Stability Tests ===\n");
// Test 1: Deterministic
println!("Test 1: Same inputs → Same fingerprint");
let uuid_pair = "CONTAINER-UUID:VOLUME-UUID";
let capacity = 1_000_000_000_000u64; // 1TB
let device_id = Uuid::new_v4();
let fp1 = VolumeFingerprint::new(uuid_pair, capacity, "APFS");
let fp2 = VolumeFingerprint::new(uuid_pair, capacity, "APFS");
// Test 1: Primary volume stability
println!("Test 1: Primary volume - Same inputs → Same fingerprint");
let mount_point = PathBuf::from("/System/Volumes/Data");
let fp1 = VolumeFingerprint::from_primary_volume(&mount_point, device_id);
let fp2 = VolumeFingerprint::from_primary_volume(&mount_point, device_id);
println!(" First run: {}", fp1.short_id());
println!(" Second run: {}", fp2.short_id());
println!(" Match: {}\n", fp1 == fp2);
// Test 2: Total capacity vs consumed capacity
println!("Test 2: TOTAL capacity (stable) vs CONSUMED (changes)");
let container_total = 1_000_000_000_000u64; // Physical drive: 1TB (never changes)
let consumed_today = 50_000_000_000u64; // Used space: 50GB today
let consumed_tomorrow = 100_000_000_000u64; // Used space: 100GB tomorrow
// Test 2: External volume with dotfile UUID
println!("Test 2: External volume - Dotfile UUID provides stability");
let spacedrive_id = Uuid::new_v4();
let fp_ext1 = VolumeFingerprint::from_external_volume(spacedrive_id, device_id);
let fp_ext2 = VolumeFingerprint::from_external_volume(spacedrive_id, device_id);
let fp_with_total = VolumeFingerprint::new(uuid_pair, container_total, "APFS");
let fp_with_consumed_50 = VolumeFingerprint::new(uuid_pair, consumed_today, "APFS");
let fp_with_consumed_100 = VolumeFingerprint::new(uuid_pair, consumed_tomorrow, "APFS");
println!(" With same dotfile UUID: {} == {}", fp_ext1.short_id(), fp_ext2.short_id());
println!(" Match: {}\n", fp_ext1 == fp_ext2);
println!(
" Using total (1TB): {}",
fp_with_total.short_id()
);
println!(
" Using consumed (50GB today): {}",
fp_with_consumed_50.short_id()
);
println!(
" Using consumed (100GB tmrw): {}",
fp_with_consumed_100.short_id()
);
println!(" Total stays same: {}", fp_with_total == fp_with_total);
println!(
" Consumed changes: {} (BAD!)\n",
fp_with_consumed_50 != fp_with_consumed_100
);
// Test 3: Network volume stability
println!("Test 3: Network volume - Backend ID provides stability");
let backend_id = "s3";
let bucket_name = "my-bucket";
// Test 3: Disk IDs vs UUIDs
println!("Test 3: disk3 → disk4 on reboot (unstable) vs UUID (stable)");
let fp_net1 = VolumeFingerprint::from_network_volume(backend_id, bucket_name);
let fp_net2 = VolumeFingerprint::from_network_volume(backend_id, bucket_name);
// UUID-based (stable)
let uuid_based = "ABCD-1234:VOL-5678";
let fp_uuid_run1 = VolumeFingerprint::new(uuid_based, capacity, "APFS");
let fp_uuid_run2 = VolumeFingerprint::new(uuid_based, capacity, "APFS");
println!(" First run: {}", fp_net1.short_id());
println!(" Second run: {}", fp_net2.short_id());
println!(" Match: {}\n", fp_net1 == fp_net2);
// Disk ID-based (changes on reboot)
let disk_id_before = "disk3:disk3s5"; // Before reboot
let disk_id_after = "disk4:disk4s5"; // After reboot (same physical volume!)
// Test 4: Primary volume - Mount point changes break fingerprint
println!("Test 4: Primary volume - Different mount points = Different fingerprints");
let mount1 = PathBuf::from("/Volumes/MyDrive");
let mount2 = PathBuf::from("/Volumes/MyDrive1"); // Remounted at different path
let fp_disk3 = VolumeFingerprint::new(disk_id_before, capacity, "APFS");
let fp_disk4 = VolumeFingerprint::new(disk_id_after, capacity, "APFS");
let fp_mount1 = VolumeFingerprint::from_primary_volume(&mount1, device_id);
let fp_mount2 = VolumeFingerprint::from_primary_volume(&mount2, device_id);
println!(" UUID-based before reboot: {}", fp_uuid_run1.short_id());
println!(" UUID-based after reboot: {}", fp_uuid_run2.short_id());
println!(" UUID stable: {}\n", fp_uuid_run1 == fp_uuid_run2);
println!(" Mount at /Volumes/MyDrive: {}", fp_mount1.short_id());
println!(" Mount at /Volumes/MyDrive1: {}", fp_mount2.short_id());
println!(" Different: {} (expected for primary volumes)\n", fp_mount1 != fp_mount2);
println!(" disk3 before reboot: {}", fp_disk3.short_id());
println!(" disk4 after reboot: {}", fp_disk4.short_id());
println!(
" Disk ID changes: {} (creates duplicates!)\n",
fp_disk3 != fp_disk4
);
// Test 5: External volume - Same dotfile UUID, different mount points
println!("Test 5: External volume - Dotfile UUID stable across remounts");
let ext_uuid = Uuid::new_v4();
let fp_at_mount1 = VolumeFingerprint::from_external_volume(ext_uuid, device_id);
let fp_at_mount2 = VolumeFingerprint::from_external_volume(ext_uuid, device_id);
println!(" Mounted at /Volumes/USB: {}", fp_at_mount1.short_id());
println!(" Mounted at /Volumes/USB1: {}", fp_at_mount2.short_id());
println!(" Match: {} (dotfile UUID is stable!)\n", fp_at_mount1 == fp_at_mount2);
// Summary
println!("=== Summary ===");
println!("GOOD: Use container.uuid:volume.uuid + container.total_capacity");
println!("BAD: Use container_id:disk_id (changes on reboot)");
println!("BAD: Use capacity_consumed (changes with file operations)");
println!("Primary volumes: Use mount_point + device_id");
println!(" - Stable for system volumes with fixed mount points");
println!(" - Examples: /System/Volumes/Data, C:\\, /");
println!();
println!("Current implementation:");
println!(" VolumeFingerprint::new(");
println!(" &format!(\"{{}}:{{}}\", container.uuid, volume.uuid),");
println!(" container.total_capacity, // ← Stable!");
println!(" \"APFS\"");
println!(" )");
println!("External volumes: Use dotfile UUID + device_id");
println!(" - Stable across remounts to different paths");
println!(" - Fallback to mount_point + device_id if read-only");
println!();
println!("Network volumes: Use backend_id + mount_uri");
println!(" - Stable based on cloud service and identifier");
println!(" - Examples: S3 bucket ARN, WebDAV URL");
}

View File

@@ -12,24 +12,46 @@ use std::path::PathBuf;
use std::sync::Arc;
use uuid::Uuid;
/// Dotfile name for persistent volume identification
pub const SPACEDRIVE_VOLUME_ID_FILE: &str = ".spacedrive-volume-id";
/// Unique fingerprint for a storage volume
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, Type)]
pub struct VolumeFingerprint(pub String);
impl VolumeFingerprint {
/// Create a new volume fingerprint from volume properties
/// Uses stable identifiers: UUIDs + total physical capacity + filesystem
pub fn new(name: &str, total_bytes: u64, file_system: &str) -> Self {
/// Create fingerprint for primary/system volume using stable mount point + device
/// This is used for system volumes where the mount point is stable and never changes
pub fn from_primary_volume(mount_point: &std::path::Path, device_id: Uuid) -> Self {
let mut hasher = blake3::Hasher::new();
hasher.update(b"uuid_based:");
hasher.update(name.as_bytes());
hasher.update(&total_bytes.to_be_bytes());
hasher.update(file_system.as_bytes());
hasher.update(&(name.len() as u64).to_be_bytes());
hasher.update(b"stable_primary_v1:");
hasher.update(mount_point.to_string_lossy().as_bytes());
hasher.update(device_id.as_bytes());
Self(hasher.finalize().to_hex().to_string())
}
/// Create fingerprint for external volume using dotfile UUID
/// This is used for removable drives with a .spacedrive-volume-id file
pub fn from_external_volume(spacedrive_id: Uuid, device_id: Uuid) -> Self {
let mut hasher = blake3::Hasher::new();
hasher.update(b"stable_external_v1:");
hasher.update(spacedrive_id.as_bytes());
hasher.update(device_id.as_bytes());
Self(hasher.finalize().to_hex().to_string())
}
/// Create fingerprint for network/cloud volume using backend identifier
/// This is used for network shares, cloud storage, etc.
pub fn from_network_volume(backend_id: &str, mount_uri: &str) -> Self {
let mut hasher = blake3::Hasher::new();
hasher.update(b"stable_network_v1:");
hasher.update(backend_id.as_bytes());
hasher.update(mount_uri.as_bytes());
Self(hasher.finalize().to_hex().to_string())
}
/// Create a fingerprint from a Spacedrive identifier UUID
/// Deprecated: Use from_external_volume instead for proper device binding
pub fn from_spacedrive_id(spacedrive_id: Uuid) -> Self {
let mut hasher = blake3::Hasher::new();
hasher.update(b"spacedrive_id:");
@@ -308,6 +330,8 @@ pub struct Volume {
pub is_read_only: bool,
/// Whether volume is currently mounted/available
/// Also deserializes from legacy "is_online" field for backwards compatibility
#[serde(alias = "is_online")]
pub is_mounted: bool,
/// Hardware identifier (device path, UUID, etc.)

View File

@@ -195,7 +195,7 @@ impl RpcServer {
json_payload: serde_json::Value,
core: &Arc<crate::Core>,
) -> Result<serde_json::Value, String> {
tracing::info!(
tracing::debug!(
"[RPC Operation]: method={}, library_id={:?}",
method,
library_id

View File

@@ -172,13 +172,13 @@ impl crate::infra::sync::Syncable for Model {
data: serde_json::Value,
db: &DatabaseConnection,
) -> Result<(), sea_orm::DbErr> {
tracing::info!("[DEVICE_SYNC] apply_state_change called");
tracing::debug!("[DEVICE_SYNC] apply_state_change called");
// Deserialize incoming data
let device: Model = serde_json::from_value(data)
.map_err(|e| sea_orm::DbErr::Custom(format!("Device deserialization failed: {}", e)))?;
tracing::info!(
tracing::debug!(
"[DEVICE_SYNC] Processing device: uuid={}, slug={}",
device.uuid,
device.slug
@@ -195,14 +195,14 @@ impl crate::infra::sync::Syncable for Model {
// Determine the slug to use
let slug_to_use = if let Some(existing) = existing_device {
// Device exists - keep its existing slug to avoid breaking references
tracing::info!(
tracing::debug!(
"[DEVICE_SYNC] Device exists, keeping existing slug: {}",
existing.slug
);
existing.slug
} else {
// New device - check for slug collisions
tracing::info!("[DEVICE_SYNC] New device, checking for slug collisions");
tracing::debug!("[DEVICE_SYNC] New device, checking for slug collisions");
let existing_slugs: Vec<String> = Entity::find()
.all(db)
.await?
@@ -210,7 +210,7 @@ impl crate::infra::sync::Syncable for Model {
.map(|d| d.slug.clone())
.collect();
tracing::info!(
tracing::debug!(
"[DEVICE_SYNC] Existing slugs in database: {:?}",
existing_slugs
);
@@ -219,13 +219,13 @@ impl crate::infra::sync::Syncable for Model {
crate::library::Library::ensure_unique_slug(&device.slug, &existing_slugs);
if unique_slug != device.slug {
tracing::info!(
tracing::debug!(
"[DEVICE_SYNC] Slug collision! Using '{}' instead of '{}'",
unique_slug,
device.slug
);
} else {
tracing::info!("[DEVICE_SYNC] No collision, using slug: {}", unique_slug);
tracing::debug!("[DEVICE_SYNC] No collision, using slug: {}", unique_slug);
}
unique_slug

View File

@@ -719,7 +719,7 @@ impl Model {
/// This is a safety measure to run after backfill or if the closure table
/// becomes corrupted. Rebuilds all relationships from the parent_id links.
pub async fn rebuild_all_entry_closures(db: &DatabaseConnection) -> Result<(), sea_orm::DbErr> {
tracing::info!("Starting bulk entry_closure rebuild...");
tracing::debug!("Starting bulk entry_closure rebuild...");
// Clear existing closure table
super::entry_closure::Entity::delete_many().exec(db).await?;
@@ -781,7 +781,7 @@ impl Model {
// Count final relationships
let total = super::entry_closure::Entity::find().count(db).await?;
tracing::info!(
tracing::debug!(
iterations = iteration,
total_relationships = total,
"Bulk entry_closure rebuild complete"

View File

@@ -186,7 +186,7 @@ impl Syncable for Model {
use super::tag_closure;
use sea_orm::{ConnectionTrait, DbBackend, PaginatorTrait, Statement};
tracing::info!("Starting tag_closure rebuild from tag_relationships...");
tracing::debug!("Starting tag_closure rebuild from tag_relationships...");
// Clear existing tag_closure table
tag_closure::Entity::delete_many().exec(db).await?;
@@ -263,7 +263,7 @@ impl Syncable for Model {
let total = tag_closure::Entity::find().count(db).await?;
tracing::info!(
tracing::debug!(
iterations = iteration,
total_relationships = total,
"tag_closure rebuild complete"

View File

@@ -34,7 +34,7 @@ impl MigrationTrait for Migration {
.get_connection()
.execute_unprepared(
r#"
CREATE TRIGGER entries_search_insert
CREATE TRIGGER IF NOT EXISTS entries_search_insert
AFTER INSERT ON entries
BEGIN
INSERT INTO search_index(rowid, name, extension)
@@ -49,7 +49,7 @@ impl MigrationTrait for Migration {
.get_connection()
.execute_unprepared(
r#"
CREATE TRIGGER entries_search_update
CREATE TRIGGER IF NOT EXISTS entries_search_update
AFTER UPDATE ON entries
BEGIN
UPDATE search_index SET
@@ -66,7 +66,7 @@ impl MigrationTrait for Migration {
.get_connection()
.execute_unprepared(
r#"
CREATE TRIGGER entries_search_delete
CREATE TRIGGER IF NOT EXISTS entries_search_delete
AFTER DELETE ON entries
BEGIN
DELETE FROM search_index WHERE rowid = old.id;
@@ -112,7 +112,7 @@ impl MigrationTrait for Migration {
.get_connection()
.execute_unprepared(
r#"
CREATE TRIGGER entries_search_insert
CREATE TRIGGER IF NOT EXISTS entries_search_insert
AFTER INSERT ON entries WHEN new.kind = 0
BEGIN
INSERT INTO search_index(rowid, name, extension)
@@ -127,7 +127,7 @@ impl MigrationTrait for Migration {
.get_connection()
.execute_unprepared(
r#"
CREATE TRIGGER entries_search_update
CREATE TRIGGER IF NOT EXISTS entries_search_update
AFTER UPDATE ON entries WHEN new.kind = 0
BEGIN
UPDATE search_index SET
@@ -144,7 +144,7 @@ impl MigrationTrait for Migration {
.get_connection()
.execute_unprepared(
r#"
CREATE TRIGGER entries_search_delete
CREATE TRIGGER IF NOT EXISTS entries_search_delete
AFTER DELETE ON entries WHEN old.kind = 0
BEGIN
DELETE FROM search_index WHERE rowid = old.id;

View File

@@ -73,7 +73,7 @@ impl QueryManager {
) -> QueryResult<Q::Output> {
let query_type = std::any::type_name::<Q>();
tracing::info!(
tracing::debug!(
query_type = query_type,
library_id = %library_id,
device_id = %session.auth.device_id,

View File

@@ -847,7 +847,7 @@ pub async fn run_post_backfill_rebuilds(db: Arc<DatabaseConnection>) -> Result<(
};
for (model_type, rebuild_fn) in rebuild_fns {
tracing::info!(model = %model_type, "Running post-backfill rebuild");
tracing::debug!(model = %model_type, "Running post-backfill rebuild");
rebuild_fn(db.clone()).await.map_err(|e| {
ApplyError::DatabaseError(format!("{} rebuild failed: {}", model_type, e))
})?;

View File

@@ -407,11 +407,9 @@ impl LibraryAction for VolumeAddCloudAction {
}
};
let fingerprint = VolumeFingerprint::new(
&self.input.display_name,
0, // Cloud volumes don't have a fixed size
&format!("{:?}", self.input.service),
);
// Generate stable fingerprint for cloud volume using service type and cloud identifier
let backend_id = format!("{:?}", self.input.service);
let fingerprint = VolumeFingerprint::from_network_volume(&backend_id, &cloud_identifier);
let backend_arc: Arc<dyn crate::volume::VolumeBackend> = Arc::new(backend);
let now = chrono::Utc::now();

View File

@@ -0,0 +1,145 @@
//! Volume eject action
use super::{VolumeEjectInput, VolumeEjectOutput};
use crate::{
context::CoreContext,
infra::action::error::ActionError,
volume::VolumeFingerprint,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{error, info};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeEjectAction {
input: VolumeEjectInput,
}
impl VolumeEjectAction {
pub fn new(input: VolumeEjectInput) -> Self {
Self { input }
}
}
crate::register_library_action!(VolumeEjectAction, "volumes.eject");
impl crate::infra::action::LibraryAction for VolumeEjectAction {
type Input = VolumeEjectInput;
type Output = VolumeEjectOutput;
fn from_input(input: Self::Input) -> Result<Self, String> {
Ok(VolumeEjectAction::new(input))
}
async fn execute(
self,
_library: Arc<crate::library::Library>,
context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
let fingerprint = VolumeFingerprint(self.input.fingerprint.clone());
info!("Ejecting volume with fingerprint: {}", fingerprint);
// Get the volume from the volume manager
let volume = context
.volume_manager
.get_volume(&fingerprint)
.await
.ok_or_else(|| {
ActionError::Internal(format!("Volume not found: {}", fingerprint))
})?;
// Check if volume is mounted
if !volume.is_mounted {
return Ok(VolumeEjectOutput {
fingerprint: self.input.fingerprint,
success: false,
message: Some("Volume is not mounted".to_string()),
});
}
// Platform-specific eject
let result = eject_volume_platform(&volume.mount_point.to_string_lossy()).await;
match result {
Ok(message) => {
info!("Successfully ejected volume: {}", fingerprint);
Ok(VolumeEjectOutput {
fingerprint: self.input.fingerprint,
success: true,
message: Some(message),
})
}
Err(e) => {
error!("Failed to eject volume {}: {}", fingerprint, e);
Ok(VolumeEjectOutput {
fingerprint: self.input.fingerprint,
success: false,
message: Some(format!("Eject failed: {}", e)),
})
}
}
}
fn action_kind(&self) -> &'static str {
"volumes.eject"
}
}
/// Platform-specific volume ejection
#[cfg(target_os = "macos")]
async fn eject_volume_platform(mount_point: &str) -> Result<String, String> {
use tokio::process::Command as TokioCommand;
info!("Ejecting volume at mount point: {}", mount_point);
// Use diskutil eject on macOS
let output = TokioCommand::new("diskutil")
.args(["eject", mount_point])
.output()
.await
.map_err(|e| format!("Failed to execute diskutil: {}", e))?;
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout.trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(stderr.trim().to_string())
}
}
/// Platform-specific volume ejection
#[cfg(target_os = "linux")]
async fn eject_volume_platform(mount_point: &str) -> Result<String, String> {
use tokio::process::Command as TokioCommand;
info!("Unmounting volume at mount point: {}", mount_point);
// Use umount on Linux
let output = TokioCommand::new("umount")
.arg(mount_point)
.output()
.await
.map_err(|e| format!("Failed to execute umount: {}", e))?;
if output.status.success() {
Ok(format!("Successfully unmounted {}", mount_point))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("Failed to unmount: {}", stderr.trim()))
}
}
/// Platform-specific volume ejection
#[cfg(target_os = "windows")]
async fn eject_volume_platform(_mount_point: &str) -> Result<String, String> {
// Windows requires Win32 API calls or PowerShell
Err("Volume ejection is not yet implemented on Windows. Please use Windows Explorer to safely remove the device.".to_string())
}
/// Fallback for unsupported platforms
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
async fn eject_volume_platform(_mount_point: &str) -> Result<String, String> {
Err("Volume ejection is not supported on this platform".to_string())
}

View File

@@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
/// Input for ejecting a volume
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
pub struct VolumeEjectInput {
/// Fingerprint of the volume to eject
pub fingerprint: String,
}

View File

@@ -0,0 +1,7 @@
pub mod action;
mod input;
mod output;
pub use action::VolumeEjectAction;
pub use input::VolumeEjectInput;
pub use output::VolumeEjectOutput;

View File

@@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
/// Output from volume eject operation
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type)]
pub struct VolumeEjectOutput {
/// The fingerprint of the ejected volume
pub fingerprint: String,
/// Whether the eject was successful
pub success: bool,
/// Optional message (error or success details)
pub message: Option<String>,
}

View File

@@ -208,16 +208,18 @@ impl LibraryQuery for VolumeListQuery {
VolumeFilter::TrackedOnly | VolumeFilter::All => {
// For tracked volumes, prefer live data if available, otherwise use DB
for tracked_vol in tracked_map.values() {
if let Some(live_vol) = live_volumes_map.remove(&tracked_vol.fingerprint) {
if let Some(mut live_vol) = live_volumes_map.remove(&tracked_vol.fingerprint) {
// Use live volume data (current device, online)
// Mark as tracked since it's in the database
live_vol.is_tracked = true;
live_vol.library_id = Some(library_id);
volumes.push(live_vol);
} else {
// Volume is offline or on another device
// Skip offline volumes from current device to avoid duplicates
if tracked_vol.device_id == current_device_id && !tracked_vol.is_online {
continue;
}
volumes.push(tracked_vol.to_tracked_volume().to_offline_volume());
// Volume is offline or on another device
// Skip offline volumes from current device to avoid duplicates
if tracked_vol.device_id == current_device_id && !tracked_vol.is_online {
continue;
}
volumes.push(tracked_vol.to_tracked_volume().to_offline_volume());
}
}

View File

@@ -6,8 +6,10 @@
//! - Adding/removing cloud volumes
//! - Listing volumes
//! - Ephemeral indexing entire volumes
//! - Ejecting removable volumes
pub mod add_cloud;
pub mod eject;
pub mod index;
pub mod list;
pub mod refresh;
@@ -17,6 +19,7 @@ pub mod track;
pub mod untrack;
pub use add_cloud::{action::VolumeAddCloudAction, VolumeAddCloudOutput};
pub use eject::{VolumeEjectAction, VolumeEjectInput, VolumeEjectOutput};
pub use index::{IndexVolumeAction, IndexVolumeInput, IndexVolumeOutput};
pub use list::{VolumeFilter, VolumeListOutput, VolumeListQuery, VolumeListQueryInput};
pub use refresh::{action::VolumeRefreshAction, VolumeRefreshOutput};

View File

@@ -53,7 +53,7 @@ impl LibraryAction for VolumeSpeedTestAction {
.map_err(|e| ActionError::InvalidInput(format!("Speed test failed: {}", e)))?;
// Get the updated volume with speed test results
let volume = context
let mut volume = context
.volume_manager
.get_volume(&self.input.fingerprint)
.await
@@ -65,6 +65,39 @@ impl LibraryAction for VolumeSpeedTestAction {
let read_speed = volume.read_speed_mbps.unwrap_or(0);
let write_speed = volume.write_speed_mbps.unwrap_or(0);
// Save results to database
context
.volume_manager
.save_speed_test_results(
&self.input.fingerprint,
read_speed,
write_speed,
&[library.clone()],
)
.await
.map_err(|e| {
ActionError::InvalidInput(format!("Failed to save speed test results: {}", e))
})?;
// Update volume timestamps to match what was saved to database
volume.updated_at = chrono::Utc::now();
// Log the volume data before emitting to verify it has speeds
tracing::info!(
"Emitting ResourceChanged for volume '{}' with speeds: read={}MB/s write={}MB/s",
volume.name,
volume.read_speed_mbps.unwrap_or(0),
volume.write_speed_mbps.unwrap_or(0)
);
// Emit ResourceChanged event for the volume with complete data
use crate::domain::resource::EventEmitter;
volume
.emit_changed(&context.events)
.map_err(|e| {
ActionError::Internal(format!("Failed to emit volume event: {}", e))
})?;
// Return native output directly
Ok(VolumeSpeedTestOutput::new(
self.input.fingerprint,

View File

@@ -631,9 +631,6 @@ impl NetworkingEventLoop {
// Spawn reconnection with a small delay to prevent immediate retry loops
tokio::spawn(async move {
// Wait 2 seconds before attempting reconnection to avoid tight loop
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
crate::service::network::core::NetworkingService::attempt_device_reconnection(
device_id,
persisted_device,

View File

@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
use tracing::info;
/// Persisted paired device data (plain data structure)
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -107,7 +108,7 @@ impl DevicePersistence {
/// Load paired devices from key manager (decrypt)
pub async fn load_paired_devices(&self) -> Result<HashMap<Uuid, PersistedPairedDevice>> {
let device_ids = self.get_device_list().await?;
tracing::info!("Loading {} device IDs from persistence", device_ids.len());
tracing::debug!("Loading {} device IDs from persistence", device_ids.len());
let mut devices = HashMap::new();
for device_id in device_ids {
@@ -149,7 +150,7 @@ impl DevicePersistence {
}
}
tracing::info!(
tracing::debug!(
"Successfully loaded {} paired devices from persistence",
devices.len()
);
@@ -288,7 +289,7 @@ impl DevicePersistence {
let should_reconnect = !is_expired && !is_blocked;
// Debug logging
eprintln!(
info!(
"[AUTO-RECONNECT] Device {}: trust={:?}, expired={}, blocked={}, include={}",
device.device_info.device_name,
device.trust_level,

View File

@@ -336,7 +336,7 @@ impl SyncProtocolHandler {
None
};
info!(
debug!(
count = entries.len(),
has_more = has_more,
has_state_snapshot = current_state.is_some(),

View File

@@ -22,7 +22,7 @@ use chrono::{DateTime, Utc};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{oneshot, Mutex};
use tracing::{info, warn};
use tracing::{debug, info, warn};
use uuid::Uuid;
/// Manages backfill process for new devices
@@ -151,7 +151,7 @@ impl BackfillManager {
let selected_peer =
select_backfill_peer(available_peers.clone()).map_err(|e| anyhow::anyhow!("{}", e))?;
info!(
debug!(
selected_peer = %selected_peer,
"Selected backfill peer"
);
@@ -195,11 +195,18 @@ impl BackfillManager {
// Set state to Backfilling
{
let old_state = self.peer_sync.state().await;
let mut state = self.peer_sync.state.write().await;
*state = DeviceSyncState::Backfilling {
peer: selected_peer,
progress: 0,
};
info!(
from_state = ?old_state,
to_state = ?DeviceSyncState::Backfilling { peer: selected_peer, progress: 0 },
peer = %selected_peer,
"Sync state transition"
);
}
// Phase 2: Backfill shared resources FIRST (entries depend on content_identities)
@@ -214,7 +221,7 @@ impl BackfillManager {
// Phase 3.5: Run post-backfill rebuilds via registry (polymorphic)
// Models that registered post_backfill_rebuild will have their derived tables rebuilt
// (e.g., entry_closure for entries, tag_closure for tag_relationships)
info!("Running post-backfill rebuilds via registry...");
debug!("Running post-backfill rebuilds via registry...");
if let Err(e) =
crate::infra::sync::registry::run_post_backfill_rebuilds(self.peer_sync.db().clone())
.await
@@ -360,14 +367,14 @@ impl BackfillManager {
primary_peer: Uuid,
_since_watermark: Option<chrono::DateTime<chrono::Utc>>, // Deprecated: use per-resource watermarks
) -> Result<Option<String>> {
info!("Backfilling device-owned state with per-resource watermarks");
debug!("Backfilling device-owned state with per-resource watermarks");
// Compute sync order based on model dependencies to prevent FK violations
let sync_order = crate::infra::sync::compute_registry_sync_order()
.await
.map_err(|e| anyhow::anyhow!("Failed to compute sync order: {}", e))?;
info!(
debug!(
sync_order = ?sync_order,
"Computed dependency-ordered sync sequence"
);
@@ -389,7 +396,7 @@ impl BackfillManager {
.get_resource_watermark(primary_peer, &model_type)
.await?;
info!(
debug!(
model_type = %model_type,
watermark = ?resource_watermark,
"Backfilling resource type with per-resource watermark"
@@ -405,7 +412,7 @@ impl BackfillManager {
)
.await?;
info!(
debug!(
model_type = %model_type,
progress = checkpoint.progress,
final_checkpoint = ?checkpoint.resume_token,
@@ -420,7 +427,7 @@ impl BackfillManager {
.update_resource_watermark(primary_peer, &model_type, max_ts)
.await?;
info!(
debug!(
model_type = %model_type,
watermark = %max_ts,
"Updated resource watermark from received data"
@@ -428,9 +435,9 @@ impl BackfillManager {
} else {
// No data received - watermark MUST NOT advance!
// If we advanced it, we'd filter out unsynced data permanently
info!(
debug!(
model_type = %model_type,
"No data received, watermark unchanged (prevents data loss)"
"No data received, watermark unchanged"
);
}
@@ -438,7 +445,7 @@ impl BackfillManager {
final_checkpoint = checkpoint.resume_token;
}
info!("Device-owned state backfill complete (all resource types)");
debug!("Device-owned state backfill complete");
// Return the final checkpoint for legacy watermark update
Ok(final_checkpoint)
@@ -465,7 +472,7 @@ impl BackfillManager {
continue; // Already done
}
info!(
debug!(
peer = %peer,
model_type = %model_type,
"Backfilling model type"
@@ -607,7 +614,7 @@ impl BackfillManager {
// Add failed records to dependency tracker
if !result.failed.is_empty() {
tracing::info!(
tracing::warn!(
model_type = %model_type,
failed_count = result.failed.len(),
"Records have missing FK dependencies - adding to dependency tracker for retry"
@@ -682,7 +689,7 @@ impl BackfillManager {
self.peer_sync.dependency_tracker().resolve(uuid).await;
if !waiting_updates.is_empty() {
tracing::info!(
tracing::debug!(
resolved_uuid = %uuid,
model_type = %model_type,
waiting_count = waiting_updates.len(),
@@ -768,7 +775,8 @@ impl BackfillManager {
if let Some(hlc) = since_hlc {
info!("Backfilling shared resources incrementally since {:?}", hlc);
} else {
info!("Backfilling shared resources (full)");
// NOTE: we keep hitting this almost always, I don't think I've ever seen the above log. This concerns me greatly.
info!("Backfilling all shared resources");
}
// Request shared changes from peer in batches (can be 100k+ records)
@@ -822,7 +830,7 @@ impl BackfillManager {
};
if let Some(records_array) = records_value.as_array() {
info!(
debug!(
model_type = %model_type,
count = records_array.len(),
"Applying current state snapshot for pre-sync data"
@@ -1066,7 +1074,7 @@ impl BackfillManager {
"Failed to send ACK for shared changes (pruning may be delayed)"
);
} else {
info!(
debug!(
peer = %peer,
hlc = %up_to_hlc,
batch_size = batch_size,
@@ -1093,14 +1101,14 @@ impl BackfillManager {
// Log progress every 10,000 records for large backfills
if total_applied >= last_progress_log + 10_000 {
info!(
debug!(
total_applied = total_applied,
batch_size = batch_size,
"Backfilling shared resources - progress update"
);
last_progress_log = total_applied;
} else {
info!(
debug!(
"Applied {} shared changes (total: {})",
batch_size, total_applied
);
@@ -1120,7 +1128,7 @@ impl BackfillManager {
}
}
info!(
debug!(
"Shared resources backfill complete (total: {} entries)",
total_applied
);
@@ -1140,7 +1148,7 @@ impl BackfillManager {
// - Any peer entries created after now will have higher timestamp regardless of device_id
if last_hlc.is_none() && received_any_data {
let watermark_hlc = self.peer_sync.hlc_generator().lock().await.next();
info!(
debug!(
watermark_hlc = %watermark_hlc,
"Snapshot-only sync (no peer log entries), using current time as watermark"
);

View File

@@ -364,8 +364,15 @@ impl SyncService {
Err(e) => {
warn!("Automatic backfill failed: {}", e);
// Reset state to Uninitialized so retry logic runs
let old_state = peer_sync.state().await;
let mut state = peer_sync.state.write().await;
*state = DeviceSyncState::Uninitialized;
info!(
from_state = ?old_state,
to_state = ?DeviceSyncState::Uninitialized,
reason = "backfill_failed",
"Sync state transition"
);
// Reset flag to retry on next loop
backfill_attempted = false;
}
@@ -432,8 +439,15 @@ impl SyncService {
retry_state.record_success(); // Reset retry state
// Transition to Uninitialized to trigger full backfill
let old_state = peer_sync.state().await;
let mut state = peer_sync.state.write().await;
*state = DeviceSyncState::Uninitialized;
info!(
from_state = ?old_state,
to_state = ?DeviceSyncState::Uninitialized,
reason = "too_many_catchup_failures",
"Sync state transition"
);
backfill_attempted = false; // Allow backfill to run again
continue; // Skip to next iteration
}
@@ -452,8 +466,15 @@ impl SyncService {
// Transition to CatchingUp state
{
let old_state = peer_sync.state().await;
let mut state = peer_sync.state.write().await;
*state = DeviceSyncState::CatchingUp { buffered_count: 0 };
info!(
from_state = ?old_state,
to_state = ?DeviceSyncState::CatchingUp { buffered_count: 0 },
reason = "incremental_catchup",
"Sync state transition"
);
}
// Perform incremental catch-up using watermarks
@@ -469,15 +490,29 @@ impl SyncService {
info!("Incremental catch-up completed");
retry_state.record_success();
// Transition back to Ready
let old_state = peer_sync.state().await;
let mut state = peer_sync.state.write().await;
*state = DeviceSyncState::Ready;
info!(
from_state = ?old_state,
to_state = ?DeviceSyncState::Ready,
reason = "catchup_completed",
"Sync state transition"
);
}
Err(e) => {
warn!("Incremental catch-up failed: {}", e);
retry_state.record_failure();
// Transition back to Ready even on error
let old_state = peer_sync.state().await;
let mut state = peer_sync.state.write().await;
*state = DeviceSyncState::Ready;
info!(
from_state = ?old_state,
to_state = ?DeviceSyncState::Ready,
reason = "catchup_failed_but_continuing",
"Sync state transition"
);
}
}
}

View File

@@ -426,7 +426,7 @@ impl PeerSync {
.await
.map_err(|e| anyhow::anyhow!("Failed to persist peer watermark: {}", e))?;
info!(
debug!(
peer = %peer,
max_received_hlc = %hlc,
"Persisted shared watermark for peer"
@@ -2674,7 +2674,7 @@ impl PeerSync {
}
}
info!(
debug!(
count = all_records.len(),
"Retrieved device state records for backfill"
);
@@ -2741,7 +2741,7 @@ impl PeerSync {
// Truncate to limit
entries.truncate(limit);
info!(
debug!(
count = entries.len(),
has_more = has_more,
"Retrieved shared changes from peer log"
@@ -2818,11 +2818,16 @@ impl PeerSync {
}
// Set to catching up
let buffered_count = self.buffer.len().await + dep_stats.total_waiting_updates;
{
let mut state = self.state.write().await;
*state = DeviceSyncState::CatchingUp {
buffered_count: self.buffer.len().await + dep_stats.total_waiting_updates,
};
*state = DeviceSyncState::CatchingUp { buffered_count };
info!(
from_state = ?current_state,
to_state = ?DeviceSyncState::CatchingUp { buffered_count },
buffered_count = buffered_count,
"Sync state transition"
);
}
// Record state transition
@@ -2912,7 +2917,7 @@ impl PeerSync {
}
}
info!(
debug!(
state_changes = state_changes_to_broadcast.len(),
shared_changes = shared_changes_to_broadcast.len(),
"Processing buffered updates - will broadcast to peers after local application"
@@ -2975,9 +2980,15 @@ impl PeerSync {
}
// Now ready!
let current_state_before_ready = self.state().await;
{
let mut state = self.state.write().await;
*state = DeviceSyncState::Ready;
info!(
from_state = ?current_state_before_ready,
to_state = ?DeviceSyncState::Ready,
"Sync state transition"
);
}
// Record state transition

View File

@@ -335,15 +335,10 @@ pub fn containers_to_volumes(
Vec::new()
};
// Create volume fingerprint using stable identifiers only
// Use UUIDs for fingerprint (stable across reboots), not disk IDs (disk3 can become disk4)
// DO NOT use capacity_consumed as it changes when files are added/deleted!
// Use container total capacity (stable for the physical drive)
let fingerprint = crate::volume::types::VolumeFingerprint::new(
&format!("{}:{}", container.uuid, volume_info.uuid),
container.total_capacity, // Use container capacity (stable), not consumed (changes)
"APFS",
);
// Create stable volume fingerprint for APFS volumes
// APFS volumes are always local system/primary volumes, use mount_point + device_id
let fingerprint =
crate::volume::types::VolumeFingerprint::from_primary_volume(mount_point, device_id);
debug!(
"APFS_CONVERT: Generated fingerprint {} for volume '{}' (consumed: {} bytes)",

View File

@@ -82,6 +82,7 @@ pub struct VolumeManager {
/// Weak reference to library manager for database operations
library_manager: Arc<RwLock<Option<Weak<LibraryManager>>>>,
}
impl VolumeManager {
@@ -111,7 +112,7 @@ impl VolumeManager {
/// Initialize the volume manager and perform initial detection
#[instrument(skip(self))]
pub async fn initialize(&self) -> VolumeResult<()> {
pub async fn initialize(self: &Arc<Self>) -> VolumeResult<()> {
info!("Initializing volume manager");
// Perform initial volume detection (for local volumes)
@@ -470,6 +471,7 @@ impl VolumeManager {
&events,
&config,
&library_manager,
None,
)
.await
{
@@ -552,6 +554,7 @@ impl VolumeManager {
&events,
&config,
&library_manager,
None,
)
.await
{
@@ -591,7 +594,7 @@ impl VolumeManager {
/// Refresh all volumes and detect changes
#[instrument(skip(self))]
pub async fn refresh_volumes(&self) -> VolumeResult<()> {
pub async fn refresh_volumes(self: &Arc<Self>) -> VolumeResult<()> {
Self::refresh_volumes_internal(
self.device_id,
&self.volumes,
@@ -599,6 +602,7 @@ impl VolumeManager {
&self.events,
&self.config,
&self.library_manager,
Some(self.clone()),
)
.await
}
@@ -611,11 +615,13 @@ impl VolumeManager {
events: &Arc<EventBus>,
config: &VolumeDetectionConfig,
library_manager: &RwLock<Option<Weak<LibraryManager>>>,
manager: Option<Arc<VolumeManager>>,
) -> VolumeResult<()> {
debug!("Refreshing volumes for device {}", device_id);
// Detect current volumes
let detected_volumes = detection::detect_volumes(device_id, config).await?;
debug!("VOLUME_DETECT: Detected {} volumes", detected_volumes.len());
for vol in &detected_volumes {
debug!(
@@ -628,7 +634,7 @@ impl VolumeManager {
}
// Query database for tracked volumes to merge metadata
let mut tracked_volumes_map: HashMap<VolumeFingerprint, (Uuid, Option<String>)> =
let mut tracked_volumes_map: HashMap<VolumeFingerprint, (Uuid, Option<String>, Option<u64>, Option<u64>)> =
HashMap::new();
if let Some(lib_mgr) = library_manager.read().await.as_ref() {
if let Some(lib_mgr) = lib_mgr.upgrade() {
@@ -652,10 +658,17 @@ impl VolumeManager {
);
for db_vol in tracked_vols {
let fingerprint = VolumeFingerprint(db_vol.fingerprint.clone());
debug!("DB_MERGE: Found tracked volume - fingerprint: {}, display_name: {:?}",
fingerprint.short_id(), db_vol.display_name);
tracked_volumes_map
.insert(fingerprint, (library.id(), db_vol.display_name));
debug!("DB_MERGE: Found tracked volume - fingerprint: {}, display_name: {:?}, read_speed: {:?}, write_speed: {:?}",
fingerprint.short_id(), db_vol.display_name, db_vol.read_speed_mbps, db_vol.write_speed_mbps);
tracked_volumes_map.insert(
fingerprint,
(
library.id(),
db_vol.display_name,
db_vol.read_speed_mbps.map(|s| s as u64),
db_vol.write_speed_mbps.map(|s| s as u64),
),
);
}
} else {
debug!(
@@ -683,10 +696,12 @@ impl VolumeManager {
seen_fingerprints.insert(fingerprint.clone());
// Merge tracked volume metadata from database
if let Some((library_id, display_name)) = tracked_volumes_map.get(&fingerprint) {
if let Some((library_id, display_name, read_speed, write_speed)) = tracked_volumes_map.get(&fingerprint) {
detected.is_tracked = true;
detected.library_id = Some(*library_id);
detected.display_name = display_name.clone();
detected.read_speed_mbps = *read_speed;
detected.write_speed_mbps = *write_speed;
}
debug!(
@@ -725,6 +740,24 @@ impl VolumeManager {
fingerprint: fingerprint.clone(),
is_mounted: new_info.is_mounted,
});
// Auto-run speed test when volume is mounted
if new_info.is_mounted
&& updated_volume.is_user_visible
&& !updated_volume.is_read_only
{
if let Some(ref mgr) = manager {
let mgr = mgr.clone();
let fp = fingerprint.clone();
let vol_name = updated_volume.name.clone();
tokio::spawn(async move {
info!("Auto-running speed test for mounted volume: {}", vol_name);
if let Err(e) = mgr.run_speed_test(&fp).await {
warn!("Auto speed test failed: {}", e);
}
});
}
}
}
// Emit ResourceChanged event for UI reactivity (only for user-visible volumes)
@@ -751,6 +784,21 @@ impl VolumeManager {
// Emit volume added event
events.emit(Event::VolumeAdded(detected.clone()));
// Auto-run speed test for newly discovered mounted volumes
if detected.is_mounted && detected.is_user_visible && !detected.is_read_only {
if let Some(ref mgr) = manager {
let mgr = mgr.clone();
let fp = fingerprint.clone();
let vol_name = detected.name.clone();
tokio::spawn(async move {
info!("Auto-running speed test for new volume: {}", vol_name);
if let Err(e) = mgr.run_speed_test(&fp).await {
warn!("Auto speed test failed: {}", e);
}
});
}
}
// Emit ResourceChanged event for UI reactivity (only for user-visible volumes)
if detected.is_user_visible {
debug!(
@@ -1306,11 +1354,39 @@ impl VolumeManager {
..Default::default()
};
debug!(
"About to insert volume into database - fingerprint: {}, device_id: {}, display_name: {:?}",
fingerprint.0,
volume.device_id,
final_display_name
);
let model = active_model
.insert(library.db().conn())
.await
.map_err(|e| VolumeError::Database(e.to_string()))?;
debug!(
"Successfully inserted volume into database - id: {}, uuid: {}",
model.id,
model.uuid
);
// Verify the insert by immediately querying it back
let verify_count = entities::volume::Entity::find()
.filter(entities::volume::Column::Fingerprint.eq(fingerprint.0.clone()))
.filter(entities::volume::Column::DeviceId.eq(volume.device_id))
.count(library.db().conn())
.await
.unwrap_or(0);
debug!(
"Verification query after insert - found {} volumes with fingerprint {} and device_id {}",
verify_count,
fingerprint.0,
volume.device_id
);
info!(
"Tracked volume '{}' for library '{}'",
final_display_name.as_ref().unwrap_or(&volume.name),
@@ -1980,6 +2056,7 @@ impl VolumeManager {
None
}
}
/// Statistics about detected volumes

View File

@@ -103,7 +103,33 @@ fn parse_df_line(
let disk_type = detect_disk_type_linux(filesystem_device)?;
let file_system = utils::parse_filesystem_type(filesystem_type);
let volume_type = classify_volume(&mount_path, &file_system, &name);
let fingerprint = VolumeFingerprint::new(&name, total_bytes, &file_system.to_string());
// Generate stable fingerprint based on volume type
let fingerprint = match volume_type {
crate::volume::types::VolumeType::External => {
// Try to read/create dotfile for external volumes
if let Some(spacedrive_id) =
utils::read_or_create_dotfile_sync(&mount_path, device_id, None)
{
VolumeFingerprint::from_external_volume(spacedrive_id, device_id)
} else {
// Fallback to mount_point + device_id for read-only external volumes
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
}
crate::volume::types::VolumeType::Network => {
// Use filesystem device as backend identifier for network volumes
VolumeFingerprint::from_network_volume(
filesystem_device,
&mount_path.to_string_lossy(),
)
}
_ => {
// Primary, UserData, Secondary, System, Virtual, Unknown
// All use stable mount_point + device_id
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
};
let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path);
@@ -247,7 +273,30 @@ pub fn create_volume_from_mount(mount: MountInfo, device_id: Uuid) -> VolumeResu
let mount_type = determine_mount_type(&mount.mount_point, &mount.device);
let disk_type = detect_disk_type_linux(&mount.device)?;
let volume_type = classify_volume(&mount_path, &file_system, &name);
let fingerprint = VolumeFingerprint::new(&name, mount.total_bytes, &file_system.to_string());
// Generate stable fingerprint based on volume type
let fingerprint = match volume_type {
crate::volume::types::VolumeType::External => {
// Try to read/create dotfile for external volumes
if let Some(spacedrive_id) =
utils::read_or_create_dotfile_sync(&mount_path, device_id, None)
{
VolumeFingerprint::from_external_volume(spacedrive_id, device_id)
} else {
// Fallback to mount_point + device_id for read-only external volumes
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
}
crate::volume::types::VolumeType::Network => {
// Use device as backend identifier for network volumes
VolumeFingerprint::from_network_volume(&mount.device, &mount_path.to_string_lossy())
}
_ => {
// Primary, UserData, Secondary, System, Virtual, Unknown
// All use stable mount_point + device_id
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
};
let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path);

View File

@@ -84,22 +84,41 @@ pub async fn detect_non_apfs_volumes(
.unwrap_or(FileSystem::Other("Unknown".to_string()));
let volume_type = classify_volume(&mount_path, &file_system, &name);
let fingerprint =
VolumeFingerprint::new(&name, total_bytes, &file_system.to_string());
// Generate stable fingerprint based on volume type
let fingerprint = match volume_type {
crate::volume::types::VolumeType::External => {
// Try to read/create dotfile for external volumes
if let Some(spacedrive_id) =
utils::read_or_create_dotfile_sync(&mount_path, device_id, None)
{
VolumeFingerprint::from_external_volume(spacedrive_id, device_id)
} else {
// Fallback to mount_point + device_id for read-only external volumes
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
}
crate::volume::types::VolumeType::Network => {
// Use filesystem as backend identifier for network volumes
VolumeFingerprint::from_network_volume(
filesystem,
&mount_path.to_string_lossy(),
)
}
_ => {
// Primary, UserData, Secondary, System, Virtual, Unknown
// All use stable mount_point + device_id
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
};
// Check if volume should be user-visible
let is_user_visible = should_be_user_visible(&mount_path, &name);
// Auto-track eligibility: Primary, UserData, System volumes
// Also track Secondary volumes if they're system mounts
let auto_track_eligible = matches!(
volume_type,
crate::volume::types::VolumeType::Primary
| crate::volume::types::VolumeType::UserData
| crate::volume::types::VolumeType::System
) || (volume_type
== crate::volume::types::VolumeType::Secondary
&& mount_type == crate::volume::types::MountType::System);
// Auto-track eligibility: Only Primary volumes that are user-visible
let auto_track_eligible =
matches!(volume_type, crate::volume::types::VolumeType::Primary)
&& is_user_visible;
let now = chrono::Utc::now();

View File

@@ -135,7 +135,30 @@ fn parse_wmic_output(
let disk_type = DiskType::Unknown; // Would need additional WMI queries
let volume_type = classify_volume(&mount_path, &file_system, &name);
let fingerprint = VolumeFingerprint::new(&name, total_bytes, &file_system.to_string());
// Generate stable fingerprint based on volume type
let fingerprint = match volume_type {
crate::volume::types::VolumeType::External => {
// Try to read/create dotfile for external volumes
if let Some(spacedrive_id) =
utils::read_or_create_dotfile_sync(&mount_path, device_id, None)
{
VolumeFingerprint::from_external_volume(spacedrive_id, device_id)
} else {
// Fallback to mount_point + device_id for read-only external volumes
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
}
crate::volume::types::VolumeType::Network => {
// Use caption as backend identifier for network volumes
VolumeFingerprint::from_network_volume(caption, &mount_path.to_string_lossy())
}
_ => {
// Primary, UserData, Secondary, System, Virtual, Unknown
// All use stable mount_point + device_id
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
};
let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path);
@@ -215,7 +238,31 @@ pub fn create_volume_from_windows_info(
MountType::System
};
let volume_type = classify_volume(&mount_path, &file_system, &name);
let fingerprint = VolumeFingerprint::new(&name, info.size, &file_system.to_string());
// Generate stable fingerprint based on volume type
let fingerprint = match volume_type {
crate::volume::types::VolumeType::External => {
// Try to read/create dotfile for external volumes
if let Some(spacedrive_id) =
utils::read_or_create_dotfile_sync(&mount_path, device_id, None)
{
VolumeFingerprint::from_external_volume(spacedrive_id, device_id)
} else {
// Fallback to mount_point + device_id for read-only external volumes
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
}
crate::volume::types::VolumeType::Network => {
// Use mount path as backend identifier for network volumes
let backend_id = info.volume_guid.as_deref().unwrap_or(&mount_path.to_string_lossy());
VolumeFingerprint::from_network_volume(backend_id, &mount_path.to_string_lossy())
}
_ => {
// Primary, UserData, Secondary, System, Virtual, Unknown
// All use stable mount_point + device_id
VolumeFingerprint::from_primary_volume(&mount_path, device_id)
}
};
let mut volume = Volume::new(device_id, fingerprint, name.clone(), mount_path);

View File

@@ -333,16 +333,17 @@ mod tests {
async fn test_full_speed_test() {
let temp_dir = TempDir::new().unwrap();
let fingerprint = VolumeFingerprint::new("Test Volume", 1000000000, "test");
let now = chrono::Utc::now();
let device_id = uuid::Uuid::new_v4();
let mount_path = temp_dir.path().to_path_buf();
let fingerprint = VolumeFingerprint::from_primary_volume(&mount_path, device_id);
let now = chrono::Utc::now();
let volume = Volume {
id: uuid::Uuid::new_v4(),
fingerprint,
cloud_identifier: None,
cloud_config: None,
device_id: uuid::Uuid::new_v4(),
device_id,
name: "Test Volume".to_string(),
library_id: None,
is_tracked: false,

View File

@@ -1,10 +1,15 @@
//! Shared utilities for volume detection across platforms
use crate::volume::{
error::{VolumeError, VolumeResult},
types::FileSystem,
use crate::{
domain::volume::{SpacedriveVolumeId, SPACEDRIVE_VOLUME_ID_FILE},
volume::{
error::{VolumeError, VolumeResult},
types::FileSystem,
},
};
use tracing::warn;
use std::path::Path;
use tracing::{debug, info, warn};
use uuid::Uuid;
/// Parse size strings from df output (e.g., "1.5G", "931Gi", "1024K")
pub fn parse_size_string(size_str: &str) -> VolumeResult<u64> {
@@ -79,6 +84,130 @@ pub fn parse_filesystem_type(fs_type: &str) -> FileSystem {
}
}
/// Read or create .spacedrive-volume-id file on external volumes (async version)
/// This file provides persistent identification for removable drives
/// Returns the UUID from the file, or None if the volume is read-only
pub async fn read_or_create_dotfile(
mount_point: &Path,
device_id: Uuid,
library_id: Option<Uuid>,
) -> Option<Uuid> {
let id_file_path = mount_point.join(SPACEDRIVE_VOLUME_ID_FILE);
// Try to read existing dotfile
if let Ok(content) = tokio::fs::read_to_string(&id_file_path).await {
if let Ok(spacedrive_id) = serde_json::from_str::<SpacedriveVolumeId>(&content) {
debug!(
"Found existing dotfile ID: {} at {}",
spacedrive_id.id,
id_file_path.display()
);
return Some(spacedrive_id.id);
}
}
// Try to create new dotfile (if writable)
if !mount_point.exists() {
return None;
}
let spacedrive_id = SpacedriveVolumeId {
id: Uuid::new_v4(),
created: chrono::Utc::now(),
device_name: None,
volume_name: mount_point
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Unknown".to_string()),
device_id,
library_id: library_id.unwrap_or(Uuid::nil()),
};
if let Ok(content) = serde_json::to_string_pretty(&spacedrive_id) {
match tokio::fs::write(&id_file_path, content).await {
Ok(()) => {
info!(
"Created dotfile with ID: {} at {}",
spacedrive_id.id,
id_file_path.display()
);
return Some(spacedrive_id.id);
}
Err(e) => {
debug!(
"Could not write dotfile to {}: {}",
id_file_path.display(),
e
);
}
}
}
None
}
/// Read or create .spacedrive-volume-id file on external volumes (sync version)
/// This file provides persistent identification for removable drives
/// Returns the UUID from the file, or None if the volume is read-only
pub fn read_or_create_dotfile_sync(
mount_point: &Path,
device_id: Uuid,
library_id: Option<Uuid>,
) -> Option<Uuid> {
let id_file_path = mount_point.join(SPACEDRIVE_VOLUME_ID_FILE);
// Try to read existing dotfile
if let Ok(content) = std::fs::read_to_string(&id_file_path) {
if let Ok(spacedrive_id) = serde_json::from_str::<SpacedriveVolumeId>(&content) {
debug!(
"Found existing dotfile ID: {} at {}",
spacedrive_id.id,
id_file_path.display()
);
return Some(spacedrive_id.id);
}
}
// Try to create new dotfile (if writable)
if !mount_point.exists() {
return None;
}
let spacedrive_id = SpacedriveVolumeId {
id: Uuid::new_v4(),
created: chrono::Utc::now(),
device_name: None,
volume_name: mount_point
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "Unknown".to_string()),
device_id,
library_id: library_id.unwrap_or(Uuid::nil()),
};
if let Ok(content) = serde_json::to_string_pretty(&spacedrive_id) {
match std::fs::write(&id_file_path, content) {
Ok(()) => {
info!(
"Created dotfile with ID: {} at {}",
spacedrive_id.id,
id_file_path.display()
);
return Some(spacedrive_id.id);
}
Err(e) => {
debug!(
"Could not write dotfile to {}: {}",
id_file_path.display(),
e
);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -4,6 +4,7 @@ import {AnimatePresence, motion} from 'framer-motion';
import {useEffect, useMemo} from 'react';
import {Outlet, useLocation, useParams} from 'react-router-dom';
import {Inspector} from './components/Inspector/Inspector';
import {JobsProvider} from './components/JobManager/hooks/JobsContext';
import {
PREVIEW_LAYER_ID,
QuickPreviewController,
@@ -271,15 +272,17 @@ function ShellLayoutContent() {
export function ShellLayout() {
return (
<TopBarProvider>
<SelectionProvider>
<ExplorerProvider>
{/* Sync tab navigation and defaults with router */}
<TabNavigationSync />
<TabDefaultsSync />
<ShellLayoutContent />
</ExplorerProvider>
</SelectionProvider>
</TopBarProvider>
<JobsProvider>
<TopBarProvider>
<SelectionProvider>
<ExplorerProvider>
{/* Sync tab navigation and defaults with router */}
<TabNavigationSync />
<TabDefaultsSync />
<ShellLayoutContent />
</ExplorerProvider>
</SelectionProvider>
</TopBarProvider>
</JobsProvider>
);
}

View File

@@ -28,7 +28,7 @@ import type {File} from '@sd/ts-client';
import {toast} from '@sd/ui';
import clsx from 'clsx';
import {useState} from 'react';
import {useJobs} from '../../../components/JobManager/hooks/useJobs';
import {useJobsContext} from '../../../components/JobManager/hooks/JobsContext';
import {TagSelectorButton} from '../../../components/Tags';
import {usePlatform} from '../../../contexts/PlatformContext';
import {useServer} from '../../../contexts/ServerContext';
@@ -136,7 +136,7 @@ function OverviewTab({file}: {file: File}) {
const generateProxy = useLibraryMutation('media.proxy.generate');
// Job tracking for long-running operations
const {jobs} = useJobs();
const {jobs} = useJobsContext();
const isSpeechJobRunning = jobs.some(
(job) =>
job.name === 'speech_to_text' &&

View File

@@ -5,7 +5,7 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { JobList } from "./components/JobList";
import { useJobs } from "./hooks/useJobs";
import { useJobsContext } from "./hooks/JobsContext";
import { CARD_HEIGHT } from "./types";
interface JobManagerPopoverProps {
@@ -18,7 +18,7 @@ export function JobManagerPopover({ className }: JobManagerPopoverProps) {
const [showOnlyRunning, setShowOnlyRunning] = useState(true);
// Unified hook for job data and badge/icon
const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel, getSpeedHistory } = useJobs();
const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel, getSpeedHistory } = useJobsContext();
// Reset filter to "active only" when popover opens
useEffect(() => {

View File

@@ -2,12 +2,12 @@ import { X, FunnelSimple } from "@phosphor-icons/react";
import { TopBarButton } from "@sd/ui";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useJobs } from "../hooks/useJobs";
import { useJobsContext } from "../hooks/JobsContext";
import { JobRow } from "./JobRow";
export function JobsScreen() {
const navigate = useNavigate();
const { jobs, pause, resume, cancel } = useJobs();
const { jobs, pause, resume, cancel } = useJobsContext();
const [showOnlyRunning, setShowOnlyRunning] = useState(false);
// Filter jobs based on toggle

View File

@@ -17,6 +17,9 @@ interface CopyJobDetailsProps {
export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) {
const generic = job.generic_progress;
const scrollContainerRef = useRef<HTMLDivElement>(null);
const prevScrollIndexRef = useRef<number>(-1);
const prevCompletedRef = useRef<number>(0);
const prevCurrentPathRef = useRef<string>("");
// Fetch copy metadata (file queue with File objects)
const { data: metadata, refetch } = useLibraryQuery({
@@ -24,9 +27,10 @@ export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) {
input: { job_id: job.id },
});
const files = metadata?.metadata?.files || [];
const fileObjects = metadata?.metadata?.file_objects || [];
// Refetch when completed count changes OR when current file changes
const prevCompletedRef = useRef<number>(0);
const prevCurrentPathRef = useRef<string>("");
useEffect(() => {
const currentCompleted = generic?.completion?.completed || 0;
const currentPath = generic?.current_path?.Physical?.path ||
@@ -43,42 +47,19 @@ export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) {
}
}, [generic?.completion?.completed, generic?.current_path, generic?.message, refetch]);
if (!generic) {
return (
<div className="p-4 text-xs text-ink-faint">
No progress data available
</div>
);
}
const files = metadata?.metadata?.files || [];
const fileObjects = metadata?.metadata?.file_objects || [];
// Create a map of entry_id → File for quick lookup
const fileMap = new Map<string, File>();
fileObjects.forEach(file => {
fileMap.set(file.id, file);
});
// Auto-scroll to center the currently copying file
// Match files by current_path from progress, not by "copying" status (which is too fast to catch)
const prevScrollIndexRef = useRef<number>(-1);
useEffect(() => {
const container = scrollContainerRef.current;
if (!container || !generic?.current_path) return;
// Extract current file path from progress
const currentPath = generic.current_path.Physical?.path || generic.current_path.Local?.path;
if (!currentPath) return;
// Find the file in our list that matches the current path
const currentIndex = files.findIndex(f => {
const filePath = f.source_path?.Physical?.path || f.source_path?.Local?.path;
return filePath === currentPath;
});
// Only scroll if the file index actually changed
if (currentIndex === -1 || currentIndex === prevScrollIndexRef.current) return;
prevScrollIndexRef.current = currentIndex;
@@ -86,11 +67,9 @@ export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) {
const currentElement = container.children[currentIndex] as HTMLElement;
if (!currentElement) return;
// Manually calculate and scroll the container
const containerRect = container.getBoundingClientRect();
const elementRect = currentElement.getBoundingClientRect();
// Calculate how much to scroll to center the element
const elementCenter = elementRect.top + elementRect.height / 2;
const containerCenter = containerRect.top + containerRect.height / 2;
const scrollOffset = elementCenter - containerCenter;
@@ -101,6 +80,20 @@ export function CopyJobDetails({ job, speedHistory }: CopyJobDetailsProps) {
});
}, [files, generic?.current_path]);
if (!generic) {
return (
<div className="p-4 text-xs text-ink-faint">
No progress data available
</div>
);
}
// Create a map of entry_id → File for quick lookup
const fileMap = new Map<string, File>();
fileObjects.forEach(file => {
fileMap.set(file.id, file);
});
return (
<div className="p-4 space-y-4">
{/* Speed graph */}

View File

@@ -0,0 +1,36 @@
import { createContext, useContext, ReactNode } from 'react';
import { useJobs } from './useJobs';
import type { SpeedSample } from './useJobs';
import type { JobListItem } from '../types';
interface JobsContextValue {
jobs: JobListItem[];
activeJobCount: number;
hasRunningJobs: boolean;
pause: (jobId: string) => Promise<void>;
resume: (jobId: string) => Promise<void>;
cancel: (jobId: string) => Promise<void>;
isLoading: boolean;
error: any;
getSpeedHistory: (jobId: string) => SpeedSample[];
}
const JobsContext = createContext<JobsContextValue | null>(null);
export function JobsProvider({ children }: { children: ReactNode }) {
const jobsData = useJobs();
return (
<JobsContext.Provider value={jobsData}>
{children}
</JobsContext.Provider>
);
}
export function useJobsContext() {
const context = useContext(JobsContext);
if (!context) {
throw new Error('useJobsContext must be used within JobsProvider');
}
return context;
}

View File

@@ -1,45 +1,79 @@
import { useNavigate } from "react-router-dom";
import { Plugs, WifiSlash } from "@phosphor-icons/react";
import { useNormalizedQuery, getVolumeIcon } from "@sd/ts-client";
import { SpaceItem } from "./SpaceItem";
import { GroupHeader } from "./GroupHeader";
import type { Volume } from "@sd/ts-client";
import {EyeSlash, Plugs, WifiSlash} from '@phosphor-icons/react';
import {getVolumeIcon, useNormalizedQuery} from '@sd/ts-client';
import type {Volume} from '@sd/ts-client';
import {useNavigate} from 'react-router-dom';
import {GroupHeader} from './GroupHeader';
import {SpaceItem} from './SpaceItem';
import {useVolumeContextMenu} from './hooks/useVolumeContextMenu';
interface VolumesGroupProps {
isCollapsed: boolean;
onToggle: () => void;
/** Filter to show tracked, untracked, or all volumes (default: "All") */
filter?: "TrackedOnly" | "UntrackedOnly" | "All";
filter?: 'TrackedOnly' | 'UntrackedOnly' | 'All';
sortableAttributes?: any;
sortableListeners?: any;
}
// Helper to render volume status indicator
const getVolumeIndicator = (volume: Volume) => (
<>
{!volume.is_tracked && (
<EyeSlash
size={14}
weight="bold"
className="text-ink-faint/50"
/>
)}
</>
);
// Component for individual volume items with context menu
function VolumeItem({volume, index, volumesLength}: {volume: Volume; index: number; volumesLength: number}) {
const contextMenu = useVolumeContextMenu({volume});
return (
<SpaceItem
key={volume.id}
item={
{
id: volume.id,
item_type: {
Volume: {
volume_id: volume.id,
name: volume.display_name || volume.name
}
}
} as any
}
volumeData={{
device_slug: volume.device_id,
mount_path: volume.mount_point || '/'
}}
rightComponent={getVolumeIndicator(volume)}
customIcon={getVolumeIcon(volume)}
allowInsertion={false}
isLastItem={index === volumesLength - 1}
onContextMenu={contextMenu.show}
/>
);
}
export function VolumesGroup({
isCollapsed,
onToggle,
filter = "All",
filter = 'All',
sortableAttributes,
sortableListeners,
sortableListeners
}: VolumesGroupProps) {
const navigate = useNavigate();
const { data: volumesData } = useNormalizedQuery({
wireMethod: "query:volumes.list",
input: { filter },
resourceType: "volume",
const {data: volumesData} = useNormalizedQuery({
wireMethod: 'query:volumes.list',
input: {filter},
resourceType: 'volume'
});
const volumes = volumesData?.volumes || [];
// Helper to render volume status indicator
const getVolumeIndicator = (volume: Volume) => (
<>
{!volume.is_tracked && (
<Plugs size={14} weight="bold" className="text-ink-faint" />
)}
</>
);
return (
<div>
<GroupHeader
@@ -54,34 +88,16 @@ export function VolumesGroup({
{!isCollapsed && (
<div className="space-y-0.5">
{volumes.length === 0 ? (
<div className="px-2 py-1 text-xs text-ink-faint">
<div className="text-ink-faint px-2 py-1 text-xs">
No volumes
</div>
) : (
volumes.map((volume, index) => (
<SpaceItem
<VolumeItem
key={volume.id}
item={
{
id: volume.id,
item_type: {
Volume: {
volume_id: volume.id,
name:
volume.display_name ||
volume.name,
},
},
} as any
}
volumeData={{
device_slug: volume.device_slug,
mount_path: volume.mount_point || "/",
}}
rightComponent={getVolumeIndicator(volume)}
customIcon={getVolumeIcon(volume)}
allowInsertion={false}
isLastItem={index === volumes.length - 1}
volume={volume}
index={index}
volumesLength={volumes.length}
/>
))
)}

View File

@@ -0,0 +1,133 @@
import {
Database,
EyeSlash,
Eye,
Gauge,
EjectSimple
} from '@phosphor-icons/react';
import type { Volume } from '@sd/ts-client';
import {
useContextMenu,
type ContextMenuItem,
type ContextMenuResult
} from '../../../hooks/useContextMenu';
import { useLibraryMutation } from '../../../contexts/SpacedriveContext';
interface UseVolumeContextMenuOptions {
volume: Volume;
}
/**
* Provides context menu functionality for volume items.
*
* Menu items include:
* - Track Volume: Add volume to library tracking
* - Untrack Volume: Remove volume from library tracking
* - Speed Test: Test read/write performance
* - Index Volume: Trigger full volume indexing
* - Eject Volume: Safely eject removable media
*/
export function useVolumeContextMenu({
volume
}: UseVolumeContextMenuOptions): ContextMenuResult {
const trackVolume = useLibraryMutation('volumes.track');
const untrackVolume = useLibraryMutation('volumes.untrack');
const speedTestVolume = useLibraryMutation('volumes.speed_test');
const indexVolume = useLibraryMutation('volumes.index');
const ejectVolume = useLibraryMutation('volumes.eject');
const isRemovable = volume.mount_type === 'External';
const items: ContextMenuItem[] = [
{
icon: Eye,
label: 'Track Volume',
onClick: async () => {
try {
await trackVolume.mutateAsync({
fingerprint: volume.fingerprint,
display_name: null
});
} catch (err) {
console.error('Failed to track volume:', err);
}
},
condition: () => !volume.is_tracked
},
{
icon: EyeSlash,
label: 'Untrack Volume',
onClick: async () => {
try {
await untrackVolume.mutateAsync({
volume_id: volume.id
});
} catch (err) {
console.error('Failed to untrack volume:', err);
}
},
variant: 'danger' as const,
condition: () => volume.is_tracked
},
{ type: 'separator' },
{
icon: Database,
label: 'Index Volume',
onClick: async () => {
try {
const result = await indexVolume.mutateAsync({
fingerprint: volume.fingerprint,
scope: 'Recursive'
});
console.log('Volume indexed:', result.message);
} catch (err) {
console.error('Failed to index volume:', err);
}
},
condition: () => volume.is_mounted
},
{
icon: Gauge,
label: 'Speed Test',
onClick: async () => {
try {
const result = await speedTestVolume.mutateAsync({
fingerprint: volume.fingerprint
});
console.log(
'Speed test complete:',
result.read_speed_mbps,
'MB/s read,',
result.write_speed_mbps,
'MB/s write'
);
} catch (err) {
console.error('Failed to run speed test:', err);
}
},
condition: () => volume.is_mounted
},
{
icon: EjectSimple,
label: 'Eject',
onClick: async () => {
try {
const result = await ejectVolume.mutateAsync({
fingerprint: volume.fingerprint
});
if (result.success) {
console.log('Volume ejected successfully');
} else {
console.error('Eject failed:', result.message);
}
} catch (err) {
console.error('Failed to eject volume:', err);
}
},
keybind: '⌘E',
condition: () => isRemovable && volume.is_mounted
}
];
return useContextMenu({ items });
}

View File

@@ -12,7 +12,7 @@ import { SpaceCustomizationPanel } from "./SpaceCustomizationPanel";
import { useSpacedriveClient } from "../../contexts/SpacedriveContext";
import { useLibraries } from "../../hooks/useLibraries";
import { usePlatform } from "../../contexts/PlatformContext";
import { useJobs } from "../JobManager/hooks/useJobs";
import { useJobsContext } from "../JobManager/hooks/JobsContext";
import { useSyncCount } from "../SyncMonitor/hooks/useSyncCount";
import { useSyncMonitor } from "../SyncMonitor/hooks/useSyncMonitor";
import { PeerList } from "../SyncMonitor/components/PeerList";
@@ -208,6 +208,7 @@ const JobsButton = memo(function JobsButton({
pause,
resume,
cancel,
getSpeedHistory,
navigate
}: {
activeJobCount: number;
@@ -216,6 +217,7 @@ const JobsButton = memo(function JobsButton({
pause: (jobId: string) => Promise<void>;
resume: (jobId: string) => Promise<void>;
cancel: (jobId: string) => Promise<void>;
getSpeedHistory: (jobId: string) => any[];
navigate: any;
}) {
const popover = usePopover();
@@ -286,7 +288,7 @@ const JobsButton = memo(function JobsButton({
}}
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
>
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} onCancel={cancel} />
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} onCancel={cancel} getSpeedHistory={getSpeedHistory} />
</motion.div>
)}
</Popover>
@@ -315,7 +317,7 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
// Get sync and job status for icons
const { onlinePeerCount, isSyncing } = useSyncCount();
const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel } = useJobs();
const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel, getSpeedHistory } = useJobsContext();
const { currentSpaceId, setCurrentSpace } = useSidebarStore();
const { data: spacesData } = useSpaces();
@@ -430,13 +432,14 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SyncButton />
<JobsButton
<JobsButton
activeJobCount={activeJobCount}
hasRunningJobs={hasRunningJobs}
jobs={jobs}
pause={pause}
resume={resume}
cancel={cancel}
getSpeedHistory={getSpeedHistory}
navigate={navigate}
/>
<TopBarButton

View File

@@ -100,7 +100,7 @@ export function useVirtualListing(): VirtualListingResult {
// Add volumes for this device
const deviceVolumes = volumes.filter(
(vol: any) => vol.device_slug === device.slug,
(vol: any) => vol.device_id === device.id,
);
virtualFiles.push(

View File

@@ -137,7 +137,7 @@ export const FileCard = memo(
const volumeData = isVolume ? (file as any)._virtual.data : null;
const hasVolumeCapacity =
volumeData?.total_capacity != null &&
volumeData?.available_capacity != null &&
volumeData?.available_space != null &&
volumeData.total_capacity > 0;
return (
@@ -199,7 +199,7 @@ export const FileCard = memo(
{showFileSize && hasVolumeCapacity && (
<VolumeSizeBar
totalBytes={Number(volumeData.total_capacity)}
availableBytes={Number(volumeData.available_capacity)}
availableBytes={Number(volumeData.available_space)}
className="mt-1.5"
/>
)}

View File

@@ -29,7 +29,7 @@ import clsx from 'clsx';
import {useEffect, useRef, useState} from 'react';
import Masonry from 'react-masonry-css';
import {JobCard} from '../../components/JobManager/components/JobCard';
import {useJobs} from '../../components/JobManager/hooks/useJobs';
import {useJobsContext} from '../../components/JobManager/hooks/JobsContext';
import {
getDeviceIcon,
useCoreQuery,
@@ -107,7 +107,7 @@ export function DevicePanel({onLocationSelect}: DevicePanelProps = {}) {
});
// Get all jobs with real-time updates (local jobs)
const {jobs: localJobs} = useJobs();
const {jobs: localJobs} = useJobsContext();
// Get remote device jobs
// TODO: This should have its own hook like useJobs, this will not work reactively
@@ -311,7 +311,7 @@ function DeviceCard({
}: DeviceCardProps) {
const deviceName = device?.name || 'Unknown Device';
const deviceIconSrc = device ? getDeviceIcon(device) : null;
const {pause, resume, getSpeedHistory} = useJobs();
const {pause, resume, cancel, getSpeedHistory} = useJobsContext();
// Format hardware specs
const cpuInfo = device?.cpu_model
? `${device.cpu_model}${device.cpu_cores_physical ? ` <20> ${device.cpu_cores_physical}C` : ''}`
@@ -430,6 +430,7 @@ function DeviceCard({
job={job}
onPause={pause}
onResume={resume}
onCancel={cancel}
getSpeedHistory={getSpeedHistory}
/>
))}

View File

@@ -1,4 +1,4 @@
import {Database, Plus} from '@phosphor-icons/react';
import {ArrowDown, ArrowUp, DotsThree, EyeSlash} from '@phosphor-icons/react';
import DatabaseIcon from '@sd/assets/icons/Database.png';
import DriveAmazonS3Icon from '@sd/assets/icons/Drive-AmazonS3.png';
import DriveDropboxIcon from '@sd/assets/icons/Drive-Dropbox.png';
@@ -6,9 +6,11 @@ import DriveGoogleDriveIcon from '@sd/assets/icons/Drive-GoogleDrive.png';
import DriveIcon from '@sd/assets/icons/Drive.png';
import HDDIcon from '@sd/assets/icons/HDD.png';
import ServerIcon from '@sd/assets/icons/Server.png';
import type {Device, VolumeItem} from '@sd/ts-client';
import type {Device, Volume} from '@sd/ts-client';
import {TopBarButton} from '@sd/ui';
import {motion} from 'framer-motion';
import {useEffect, useState} from 'react';
import {useVolumeContextMenu} from '../../components/SpacesSidebar/hooks/useVolumeContextMenu';
import {
useLibraryMutation,
useNormalizedQuery,
@@ -22,7 +24,7 @@ function getDiskTypeLabel(diskType: string): string {
}
interface VolumeBarProps {
volume: VolumeItem;
volume: Volume;
index: number;
}
@@ -34,12 +36,12 @@ interface IndexingProgress {
}
export function VolumeBar({volume, index}: VolumeBarProps) {
const trackVolume = useLibraryMutation('volumes.track');
const indexVolume = useLibraryMutation('volumes.index');
const [indexingProgress, setIndexingProgress] =
useState<IndexingProgress | null>(null);
const client = useSpacedriveClient();
const contextMenu = useVolumeContextMenu({volume: volume as any});
// Get the job ID for this volume from the store
const jobId = useVolumeIndexingStore((state) =>
state.getJobId(volume.fingerprint)
@@ -144,28 +146,6 @@ export function VolumeBar({volume, index}: VolumeBarProps) {
const currentDevice = devicesQuery.data?.find((d) => d.is_current);
const handleTrack = async () => {
try {
await trackVolume.mutateAsync({
fingerprint: volume.fingerprint
});
} catch (error) {
console.error('Failed to track volume:', error);
}
};
const handleIndex = async () => {
try {
const result = await indexVolume.mutateAsync({
fingerprint: volume.fingerprint,
scope: 'Recursive'
});
console.log('Volume indexed:', result.message);
} catch (error) {
console.error('Failed to index volume:', error);
}
};
if (!volume.total_capacity) {
return null;
}
@@ -209,7 +189,7 @@ export function VolumeBar({volume, index}: VolumeBarProps) {
typeof volume.volume_type === 'string'
? volume.volume_type
: (volume.volume_type as any)?.Other ||
JSON.stringify(volume.volume_type)
JSON.stringify(volume.volume_type)
);
return (
@@ -218,76 +198,49 @@ export function VolumeBar({volume, index}: VolumeBarProps) {
animate={{opacity: 1, y: 0}}
transition={{delay: index * 0.05}}
className="bg-app-box border-app-line/50 overflow-hidden rounded-lg border"
onContextMenu={contextMenu.show}
>
{/* Top row: Info */}
<div className="flex items-center gap-3 px-3 py-2">
{/* Top row: Info - fixed height */}
<div className="flex h-[64px] items-center gap-3 px-3">
{/* Icon */}
<img
src={iconSrc}
alt={volumeTypeStr}
className="size-6 flex-shrink-0 opacity-80"
className="size-10 flex-shrink-0 opacity-80"
/>
{/* Name, actions, and badges */}
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<div className="mb-1.5 flex items-center gap-2">
<span className="text-ink truncate text-sm font-semibold">
{volume.display_name || volume.name}
</span>
{!volume.is_mounted && (
<span className="bg-app-box text-ink-faint border-app-line rounded border px-1.5 py-0.5 text-[10px]">
Offline
</span>
)}
{!volume.is_tracked && (
<button
onClick={handleTrack}
disabled={trackVolume.isPending}
className="bg-accent/10 hover:bg-accent/20 text-accent border-accent/20 hover:border-accent/30 flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] transition-colors disabled:opacity-50"
title="Track this volume"
>
<Plus className="size-2.5" weight="bold" />
{trackVolume.isPending
? 'Tracking...'
: 'Track'}
</button>
<EyeSlash
size={14}
weight="bold"
className="text-ink-faint/50"
/>
)}
{currentDevice &&
volume.device_id === currentDevice.id && (
<button
onClick={handleIndex}
disabled={indexVolume.isPending}
className="bg-sidebar-box hover:bg-sidebar-selected text-sidebar-ink border-sidebar-line flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] transition-colors disabled:opacity-50"
title="Index this volume"
>
<Database
className="size-2.5"
weight="bold"
/>
{indexVolume.isPending
? 'Indexing...'
: 'Index'}
</button>
)}
</div>
{/* Badges under name */}
<div className="text-ink-dull flex flex-wrap items-center gap-1.5 text-[10px]">
{/* Badges under name - fixed height */}
<div className="text-ink-dull flex h-[18px] items-center gap-1.5 text-[10px]">
{fileSystem && (
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
{fileSystem}
</span>
)}
{diskType && (
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
{fileSystem}
</span>
)}
{diskType && (
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
{getDiskTypeLabel(diskType)}
</span>
)}
{volumeTypeStr && (
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
{volumeTypeStr}
</span>
)}
</span>
)}
{volumeTypeStr && (
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
{volumeTypeStr}
</span>
)}
{indexingProgress ? (
<span className="bg-accent/20 border-accent/30 text-accent rounded border px-1.5 py-0.5 font-medium">
{indexingProgress.filesIndexed.toLocaleString()}{' '}
@@ -299,9 +252,9 @@ export function VolumeBar({volume, index}: VolumeBarProps) {
)}
</span>
) : (
volume.total_file_count != null && (
volume.total_files != null && (
<span className="bg-accent/10 border-accent/20 text-accent rounded border px-1.5 py-0.5">
{volume.total_file_count.toLocaleString()}{' '}
{volume.total_files.toLocaleString()}{' '}
files
</span>
)
@@ -309,19 +262,43 @@ export function VolumeBar({volume, index}: VolumeBarProps) {
</div>
</div>
{/* Capacity info */}
<div className="flex-shrink-0 text-right">
{/* Capacity info - fixed 3-row layout */}
<div className="flex h-[48px] flex-shrink-0 flex-col justify-between text-right">
<div className="text-ink text-sm font-medium">
{formatBytes(totalCapacity)}
</div>
<div className="text-ink-dull text-[10px]">
{formatBytes(availableBytes)} free
</div>
<div className="text-ink-faint flex h-3.5 items-center justify-end gap-1.5 text-[10px]">
{volume.read_speed_mbps && (
<span className="flex items-center gap-0.5">
<ArrowDown size={10} weight="bold" />
{volume.read_speed_mbps}MB/s
</span>
)}
{volume.write_speed_mbps && (
<span className="flex items-center gap-0.5">
<ArrowUp size={10} weight="bold" />
{volume.write_speed_mbps}MB/s
</span>
)}
</div>
</div>
{/* Three dots button - far right */}
<TopBarButton
icon={DotsThree}
onClick={(e) => {
e.stopPropagation();
contextMenu.show(e);
}}
title="Volume actions"
/>
</div>
{/* Bottom: Full-width capacity bar with padding */}
<div className="px-3 pb-3 pt-2">
<div className="px-3 pb-3 pt-1">
<div className="bg-app border-app-line relative h-8 overflow-hidden rounded-md border">
{/* Base capacity visualization */}
<div className="flex h-full">

View File

@@ -1,5 +1,12 @@
@import "@sd/ui/style/colors.scss";
/* Prevent overscroll/rubber-band effect on macOS */
html,
body {
overscroll-behavior: none;
overflow: hidden;
}
/* Top bar blur effect (macOS specific) */
.top-bar-blur {
backdrop-filter: saturate(120%) blur(18px);

View File

@@ -4127,6 +4127,7 @@ available_space: number;
is_read_only: boolean;
/**
* Whether volume is currently mounted/available
* Also deserializes from legacy "is_online" field for backwards compatibility
*/
is_mounted: boolean;
/**
@@ -4188,6 +4189,32 @@ export type VolumeAddCloudInput = { service: CloudServiceType; display_name: str
export type VolumeAddCloudOutput = { fingerprint: VolumeFingerprint; volume_name: string; service: CloudServiceType };
/**
* Input for ejecting a volume
*/
export type VolumeEjectInput = {
/**
* Fingerprint of the volume to eject
*/
fingerprint: string };
/**
* Output from volume eject operation
*/
export type VolumeEjectOutput = {
/**
* The fingerprint of the ejected volume
*/
fingerprint: string;
/**
* Whether the eject was successful
*/
success: boolean;
/**
* Optional message (error or success details)
*/
message: string | null };
export type VolumeFilter =
/**
* Only return tracked volumes
@@ -4408,6 +4435,7 @@ export type LibraryAction =
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
| { type: 'volumes.eject'; input: VolumeEjectInput; output: VolumeEjectOutput }
| { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput }
| { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput }
| { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
@@ -4520,6 +4548,7 @@ export const WIRE_METHODS = {
'tags.apply': 'action:tags.apply.input',
'tags.create': 'action:tags.create.input',
'volumes.add_cloud': 'action:volumes.add_cloud.input',
'volumes.eject': 'action:volumes.eject.input',
'volumes.index': 'action:volumes.index.input',
'volumes.refresh': 'action:volumes.refresh.input',
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',