Surface peer replicas in sources.list and serve remote listings

sources.list now appends paired devices' sources from the peer-mount
replica set, attributed with the owning device's id and label and
marked status "replica". The overview device card renders them, so a
paired NAS shows its sources instead of "No volumes".

directory_listing serves paths on another device straight from that
device's replicated index: same conversion as the local cached path
(extracted into shared helpers), no indexer dispatch, since indexing
happens on the owner. Verified from titan against the Mac's replica.

sync_device now holds a per-device lock; the refresh loop clears the
debounce before running, so a reconnect in that window was loading the
same snapshots twice in parallel.
This commit is contained in:
Jamie Pine committed 2026-09-15 05:20:07 -07:00
1 parent a0180d1b7e
commit 3ac1f0af78
6 files changed
+342 -66

No files matched your search

+119 -60
View File
@@ -145,6 +145,26 @@ impl DirectoryListingQuery {
use crate::domain::file::File;
use crate::ops::indexing::{IndexScope, IndexerJob, IndexerJobConfig};
// A path on another device is served from its replica, kept current
// by the peer-mount plane. Indexing happens on the owner, so there is
// no dispatch fall-through: what the replica holds is the answer.
if let SdPath::Physical { device_slug, path } = &self.input.path {
if *device_slug != crate::device::get_current_device_slug() {
if let Some(output) = self.list_remote_replica(&context, device_slug, path).await {
return Ok(output);
}
tracing::debug!(
"no replica covers {} on device '{device_slug}'",
path.display()
);
return Ok(DirectoryListingOutput {
files: Vec::new(),
total_count: 0,
has_more: false,
});
}
}
// Get the local path for cache lookup
let local_path = match &self.input.path {
SdPath::Physical { path, .. } => path.clone(),
@@ -199,67 +219,12 @@ impl DirectoryListingQuery {
local_path.display()
);
// Convert cached entries to File objects with lazy UUID assignment
let mut index_write = index.write().await;
let mut files = Vec::new();
for child_path in children {
if let Some(metadata) = index_write.get_entry_ref(&child_path) {
// Bundle internals are lensed out with hidden files:
// the package browses as one opaque item.
if !self.input.include_hidden.unwrap_or(false)
&& (metadata.is_hidden
|| crate::ops::indexing::lens::is_bundle_internal(&child_path))
{
continue;
}
let entry_uuid = index_write.get_or_assign_uuid(&child_path);
let entry_sd_path = SdPath::Physical {
device_slug: match &self.input.path {
SdPath::Physical { device_slug, .. } => device_slug.clone(),
_ => String::new(),
},
path: child_path.clone(),
};
let content_kind = index_write.get_content_kind(&child_path);
let mut file =
File::from_ephemeral(entry_uuid, &metadata, entry_sd_path);
file.content_kind = content_kind;
// Directories report their subtree rollup rather
// than the directory entry's own on-disk size.
if metadata.kind == crate::ops::indexing::state::EntryKind::Directory {
if let Some(bytes) = index_write.subtree_size(&child_path) {
file.size = bytes;
}
}
files.push(file);
}
}
drop(index_write);
self.sort_files(&mut files);
let total_count = files.len() as u32;
let has_more = if let Some(limit) = self.input.limit {
if files.len() > limit as usize {
files.truncate(limit as usize);
true
} else {
false
}
} else {
false
let device_slug = match &self.input.path {
SdPath::Physical { device_slug, .. } => device_slug.clone(),
_ => String::new(),
};
return Ok(DirectoryListingOutput {
files,
total_count,
has_more,
});
let files = self.files_from_index(&index, children, &device_slug).await;
return Ok(self.finalize_listing(files));
}
} else {
// Index exists but doesn't have this directory yet
@@ -365,6 +330,100 @@ impl DirectoryListingQuery {
}
/// Sort files according to the input options
/// Serve a listing from a paired device's replicated index. `None` when
/// no replica covers the path, which a caller reports as an empty
/// directory rather than an error: the owner may simply not share it.
async fn list_remote_replica(
&self,
context: &Arc<CoreContext>,
device_slug: &str,
path: &std::path::Path,
) -> Option<DirectoryListingOutput> {
let shares = crate::service::mounts::peer::remote_shares().await;
let share = shares.iter().find(|share| {
path.starts_with(&share.info.root)
&& context
.device_manager
.get_device_slug(share.device_id)
.is_some_and(|slug| slug == device_slug)
})?;
let children = { share.index.read().await.list_directory(path) }?;
let files = self
.files_from_index(&share.index, children, device_slug)
.await;
Some(self.finalize_listing(files))
}
/// Convert one directory's children in an in-memory index to `File`s,
/// applying the hidden/bundle lens. Shared by the local cache path and
/// peer replicas.
async fn files_from_index(
&self,
index: &Arc<tokio::sync::RwLock<crate::ops::indexing::ephemeral::EphemeralIndex>>,
children: Vec<std::path::PathBuf>,
device_slug: &str,
) -> Vec<File> {
let mut index_write = index.write().await;
let mut files = Vec::new();
for child_path in children {
if let Some(metadata) = index_write.get_entry_ref(&child_path) {
// Bundle internals are lensed out with hidden files:
// the package browses as one opaque item.
if !self.input.include_hidden.unwrap_or(false)
&& (metadata.is_hidden
|| crate::ops::indexing::lens::is_bundle_internal(&child_path))
{
continue;
}
let entry_uuid = index_write.get_or_assign_uuid(&child_path);
let entry_sd_path = SdPath::Physical {
device_slug: device_slug.to_string(),
path: child_path.clone(),
};
let content_kind = index_write.get_content_kind(&child_path);
let mut file = File::from_ephemeral(entry_uuid, &metadata, entry_sd_path);
file.content_kind = content_kind;
// Directories report their subtree rollup rather
// than the directory entry's own on-disk size.
if metadata.kind == crate::ops::indexing::state::EntryKind::Directory {
if let Some(bytes) = index_write.subtree_size(&child_path) {
file.size = bytes;
}
}
files.push(file);
}
}
files
}
/// Sort and cap a listing the way the input asked for.
fn finalize_listing(&self, mut files: Vec<File>) -> DirectoryListingOutput {
self.sort_files(&mut files);
let total_count = files.len() as u32;
let has_more = if let Some(limit) = self.input.limit {
if files.len() > limit as usize {
files.truncate(limit as usize);
true
} else {
false
}
} else {
false
};
DirectoryListingOutput {
files,
total_count,
has_more,
}
}
fn sort_files(&self, files: &mut Vec<File>) {
use crate::domain::file::EntryKind;
+39
View File
@@ -36,6 +36,12 @@ pub struct SourceInfo {
/// Last time the origin answered. Absent for an adapter, whose registry
/// tracks a sync cursor rather than an attachment.
pub last_seen_at: Option<String>,
/// The paired device this source was replicated from. Absent for a
/// source this library registered itself; a replica is read-only here
/// and its registry ops run on the owning device.
pub device_id: Option<Uuid>,
/// The owning device's display name, for a replica.
pub device_label: Option<String>,
}
impl SourceInfo {
@@ -80,6 +86,39 @@ impl SourceInfo {
attached,
total_bytes,
last_seen_at,
device_id: None,
device_label: None,
}
}
/// A paired device's source, replicated through the peer-mount plane.
/// The metadata is the replica's own: counts and bytes from the owner's
/// listing, `last_synced` from when the snapshot arrived here.
pub fn from_remote_share(share: &crate::service::mounts::peer::RemoteShare) -> Self {
let root = &share.info.root;
let name = root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| share.info.id.to_string());
let synced_at =
chrono::DateTime::<chrono::Utc>::from_timestamp(share.synced_at_secs as i64, 0)
.map(|at| at.to_rfc3339());
Self {
id: share.info.id,
name,
data_type: "filesystem".to_string(),
adapter_id: None,
item_count: share.info.entry_count.unwrap_or(0) as i64,
last_synced: synced_at.clone(),
status: "replica".to_string(),
root: Some(root.to_string_lossy().into_owned()),
volume_uuid: share.info.volume_uuid,
attached: share.info.attached,
total_bytes: share.info.total_bytes.map(|bytes| bytes as i64),
last_seen_at: synced_at,
device_id: Some(share.device_id),
device_label: Some(share.device_label.clone()),
}
}
}
+18 -2
View File
@@ -89,7 +89,7 @@ impl LibraryQuery for ListSourcesQuery {
.await
.map_err(|e| QueryError::Internal(format!("Failed to list sources: {e}")))?;
Ok(rows
let mut sources: Vec<SourceInfo> = rows
.into_iter()
.filter(|row| {
self.input
@@ -103,7 +103,23 @@ impl LibraryQuery for ListSourcesQuery {
.and_then(|uuid| attached_mounts.get(&uuid).cloned());
SourceInfo::from_row(row, mount.as_deref())
})
.collect())
.collect();
// Paired devices' sources, already replicated by the peer-mount
// plane. They live in memory rather than the library database, so
// they are appended here instead of joined above.
if self
.input
.data_type
.as_ref()
.is_none_or(|filter| filter == "filesystem")
{
for share in crate::service::mounts::peer::remote_shares().await {
sources.push(SourceInfo::from_remote_share(&share));
}
}
Ok(sources)
}
}
+18
View File
@@ -171,6 +171,22 @@ pub(super) fn should_sync(device_id: Uuid) -> bool {
}
}
/// One replication per device at a time. The refresh loop clears the
/// debounce before it runs, so a reconnect landing in that window would
/// otherwise fetch and load the same snapshots concurrently; serializing
/// here lets the loser skip on the generation check instead.
static SYNC_LOCKS: OnceLock<std::sync::Mutex<HashMap<Uuid, Arc<tokio::sync::Mutex<()>>>>> =
OnceLock::new();
fn sync_lock(device_id: Uuid) -> Arc<tokio::sync::Mutex<()>> {
let map = SYNC_LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
map.lock()
.unwrap()
.entry(device_id)
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
/// Pull a connected device's source list and replicate every snapshot it can
/// provide. Returns how many sources are now served for that device.
pub async fn sync_device(
@@ -178,6 +194,8 @@ pub async fn sync_device(
device_id: Uuid,
device_label: String,
) -> anyhow::Result<usize> {
let lock = sync_lock(device_id);
let _guard = lock.lock().await;
let (response, _) = request(context, device_id, &ByteRangeRequest::ListSources).await?;
let sources = match response {
ByteRangeResponse::Sources(s) => s,
@@ -22,6 +22,7 @@ import type {
Location,
LocationsListOutput,
LocationsListQueryInput,
SourceInfo,
Volume,
VolumeListOutput,
VolumeListQueryInput
@@ -35,6 +36,7 @@ import {useJobsContext} from '../../components/JobManager/hooks/JobsContext';
import {
getDeviceIcon,
useCoreQuery,
useLibraryQuery,
useNormalizedQuery
} from '../../contexts/SpacedriveContext';
import {VolumeBar} from './VolumeBar';
@@ -118,6 +120,13 @@ export function DevicePanel({onLocationSelect}: DevicePanelProps = {}) {
input: {}
});
// Sources, including paired devices' replicas. A replica carries its
// owning device's id, which is how it lands on that device's card.
const {data: sourcesData} = useLibraryQuery({
type: 'sources.list',
input: {data_type: null}
});
// Merge local and remote jobs
const allJobs = [
...localJobs,
@@ -190,6 +199,19 @@ export function DevicePanel({onLocationSelect}: DevicePanelProps = {}) {
{} as Record<string, Location[]>
);
// Group replicated sources by their owning device
const remoteSourcesByDevice = ([...(sourcesData ?? [])] as SourceInfo[]).reduce(
(acc, source) => {
if (!source.device_id) return acc;
if (!acc[source.device_id]) {
acc[source.device_id] = [];
}
acc[source.device_id].push(source);
return acc;
},
{} as Record<string, SourceInfo[]>
);
// Group jobs by device_id
const jobsByDevice = allJobs.reduce(
(acc, job) => {
@@ -221,12 +243,15 @@ export function DevicePanel({onLocationSelect}: DevicePanelProps = {}) {
const deviceJobs = jobsByDevice[device.id] || [];
const deviceLocations =
locationsByDeviceSlug[device.slug] || [];
const deviceRemoteSources =
remoteSourcesByDevice[device.id] || [];
return (
<DeviceCard
key={device.id}
device={device}
volumes={deviceVolumes}
remoteSources={deviceRemoteSources}
jobs={deviceJobs}
locations={deviceLocations}
selectedLocationId={selectedLocationId}
@@ -329,6 +354,7 @@ function ConnectionBadge({method, online, current, icon: customIcon, color: cust
interface DeviceCardProps {
device?: DeviceWithConnection;
volumes: Volume[];
remoteSources: SourceInfo[];
jobs: JobListItem[];
locations: Location[];
selectedLocationId: string | null;
@@ -340,6 +366,7 @@ interface DeviceCardProps {
function DeviceCard({
device,
volumes,
remoteSources,
jobs,
locations,
selectedLocationId,
@@ -509,7 +536,7 @@ function DeviceCard({
index={idx}
/>
))
) : (
) : remoteSources.length > 0 ? null : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="text-ink-faint">
<HardDrive className="mx-auto mb-2 size-8 opacity-20" />
@@ -517,6 +544,56 @@ function DeviceCard({
</div>
</div>
)}
{remoteSources.map((source) => (
<RemoteSourceRow key={source.id} source={source} />
))}
</div>
</div>
</div>
);
}
// A paired device's source, replicated locally through the peer-mount
// plane. There is no capacity bar because the replica knows the source's
// contents, not the drive underneath it.
function RemoteSourceRow({source}: {source: SourceInfo}) {
return (
<div className="bg-app-box border-app-line/50 overflow-hidden rounded-lg border">
<div className="flex h-[64px] items-center gap-3 px-3">
<img
src={HDDIcon}
alt=""
className="size-10 flex-shrink-0 opacity-80"
/>
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex items-center gap-2">
<span className="text-ink truncate text-sm font-semibold">
{source.name}
</span>
</div>
<div className="text-ink-dull flex h-[18px] items-center gap-1.5 text-[10px]">
<span className="bg-app-box border-app-line rounded border px-1.5 py-0.5">
Replica
</span>
{source.item_count > 0 && (
<span className="bg-accent/20 border-accent/30 text-accent rounded border px-1.5 py-0.5 font-medium">
{source.item_count.toLocaleString()} files
</span>
)}
</div>
</div>
<div className="flex flex-shrink-0 flex-col justify-between text-right">
{source.total_bytes != null && source.total_bytes > 0 && (
<div className="text-ink text-sm font-medium">
{formatBytes(source.total_bytes)}
</div>
)}
{source.last_synced && (
<div className="text-ink-dull text-[10px]">
synced{' '}
{new Date(source.last_synced).toLocaleTimeString()}
</div>
)}
</div>
</div>
</div>
+70 -3
View File
@@ -2969,7 +2969,7 @@ export type OperationSnapshot = { broadcasts_sent: number; state_changes_broadca
*/
export type Origin =
/**
* Written at library creation from the platform's known folders.
* Written by an older library initializer from the platform's known folders.
*/
"default" |
/**
@@ -3077,11 +3077,54 @@ lastSeen: string };
export type PairingSessionSummary = { id: string; state: SerializablePairingState; remote_device_id: string | null; expires_at: string | null };
export type PathAvailability = "available" | "permission_denied" | "missing" | "unavailable" | "remote" | "unsupported";
export type PathContextInput = { path: SdPath };
export type PathContextOutput = {
/**
* The path as the owning volume spells it. This removes APFS aliases from
* all containment checks while the explorer can keep displaying its input.
*/
canonical_path: SdPath; availability: PathAvailability; map_state: PathMapState; indexing_root: string | null; watcher_state: PathWatcherState; watcher_root: string | null; volume: PathVolumeContext | null; source: PathSourceContext | null;
/**
* The closest explicit pin containing this path, if one exists.
*/
location: PathLocationContext | null;
/**
* A computed system Place at this exact path, such as Desktop.
*/
system_place: string | null; storage: PathStorageContext };
export type PathLocationContext = { id: string; name: string; root: SdPath; origin: Origin; exact: boolean };
export type PathMapState = "unseen" | "indexing" | "detailed" | "summarised";
/**
* Path mapping for resolving virtual paths to actual storage locations
*/
export type PathMapping = { virtual_path: string; actual_path: string };
export type PathSourceContext = { id: string; name: string; root: string; attached: boolean };
export type PathStorageContext = {
/**
* The active volume arena can answer reads about this path.
*/
memory: boolean;
/**
* The volume arena has a machine-local restart snapshot.
*/
restart_cache: boolean;
/**
* The source has created its durable record store.
*/
source_store: boolean };
export type PathVolumeContext = { id: string; name: string; mount_point: string; tracked: boolean };
export type PathWatcherState = "active" | "inactive" | "unavailable";
/**
* Per-peer activity information
*/
@@ -3753,7 +3796,17 @@ attached: boolean; total_bytes: number | null;
* Last time the origin answered. Absent for an adapter, whose registry
* tracks a sync cursor rather than an attachment.
*/
last_seen_at: string | null };
last_seen_at: string | null;
/**
* The paired device this source was replicated from. Absent for a
* source this library registered itself; a replica is read-only here
* and its registry ops run on the owning device.
*/
device_id: string | null;
/**
* The owning device's display name, for a replica.
*/
device_label: string | null };
export type SourceItem = { id: string; external_id: string; title: string; preview: string | null; subtitle: string | null };
@@ -3782,7 +3835,7 @@ export type Space = {
*/
id: string;
/**
* Human-friendly name (e.g., "All Devices", "Work Files")
* Human-friendly name (e.g., "Default", "Work Files")
*/
name: string;
/**
@@ -3933,6 +3986,16 @@ export type SpacesListOutput = { spaces: Space[] };
export type SpacesListQueryInput = null;
export type StartupIndexingDisposition = "started" | "already_started" | "disabled";
export type StartupIndexingInput = {
/**
* Ignore the automatic-start preference for an explicit CLI request.
*/
force?: boolean };
export type StartupIndexingOutput = { disposition: StartupIndexingDisposition };
/**
* State transition event
*/
@@ -5015,6 +5078,7 @@ export type LibraryAction =
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
| { type: 'files.rename'; input: FileRenameInput; output: JobReceipt }
| { type: 'indexing.start'; input: IndexInput; output: JobReceipt }
| { type: 'indexing.startup'; input: StartupIndexingInput; output: StartupIndexingOutput }
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
| { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput }
@@ -5100,6 +5164,7 @@ export type LibraryQuery =
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
| { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput }
| { type: 'paths.context'; input: PathContextInput; output: PathContextOutput }
| { type: 'redundancy.summary'; input: RedundancySummaryInput; output: RedundancySummaryOutput }
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
| { type: 'sources.get'; input: GetSourceInput; output: SourceInfo }
@@ -5164,6 +5229,7 @@ export const WIRE_METHODS = {
'files.delete': 'action:files.delete.input',
'files.rename': 'action:files.rename.input',
'indexing.start': 'action:indexing.start.input',
'indexing.startup': 'action:indexing.startup.input',
'jobs.cancel': 'action:jobs.cancel.input',
'jobs.pause': 'action:jobs.pause.input',
'jobs.resume': 'action:jobs.resume.input',
@@ -5249,6 +5315,7 @@ export const WIRE_METHODS = {
'locations.list': 'query:locations.list',
'locations.suggested': 'query:locations.suggested',
'locations.validate_path': 'query:locations.validate_path',
'paths.context': 'query:paths.context',
'redundancy.summary': 'query:redundancy.summary',
'search.files': 'query:search.files',
'sources.get': 'query:sources.get',