diff --git a/core/src/ops/locations/update/action.rs b/core/src/ops/locations/update/action.rs
index ee497c3e5..7fcbd2931 100644
--- a/core/src/ops/locations/update/action.rs
+++ b/core/src/ops/locations/update/action.rs
@@ -66,7 +66,7 @@ impl LibraryAction for LocationUpdateAction {
.ok_or_else(|| ActionError::LocationNotFound(self.input.id))?;
// Build the update
- let mut active: entities::location::ActiveModel = location.into();
+ let mut active: entities::location::ActiveModel = location.clone().into();
if let Some(name) = &self.input.name {
active.name = Set(Some(name.clone()));
@@ -82,7 +82,76 @@ impl LibraryAction for LocationUpdateAction {
active.updated_at = Set(chrono::Utc::now());
// Execute update
- active.update(db).await.map_err(ActionError::SeaOrm)?;
+ let updated_location = active.update(db).await.map_err(ActionError::SeaOrm)?;
+
+ // Emit ResourceChanged event for UI reactivity
+ // Note: job_policies is local-only config (not synced), so we emit regular event not sync event
+ // Build LocationInfo for the event
+ let entry = entities::entry::Entity::find_by_id(
+ updated_location
+ .entry_id
+ .ok_or_else(|| ActionError::Internal("Location has no entry_id".to_string()))?,
+ )
+ .one(db)
+ .await
+ .map_err(ActionError::SeaOrm)?
+ .ok_or_else(|| ActionError::Internal("Location entry not found".to_string()))?;
+
+ let directory_path = entities::directory_paths::Entity::find_by_id(entry.id)
+ .one(db)
+ .await
+ .map_err(ActionError::SeaOrm)?
+ .ok_or_else(|| {
+ ActionError::Internal(format!(
+ "No directory path found for location {} entry {}",
+ updated_location.uuid, entry.id
+ ))
+ })?;
+
+ let device = entities::device::Entity::find_by_id(updated_location.device_id)
+ .one(db)
+ .await
+ .map_err(ActionError::SeaOrm)?
+ .ok_or_else(|| {
+ ActionError::Internal(format!(
+ "Device not found for location {}",
+ updated_location.uuid
+ ))
+ })?;
+
+ let sd_path = crate::domain::SdPath::Physical {
+ device_slug: device.slug.clone(),
+ path: directory_path.path.clone().into(),
+ };
+
+ let job_policies = updated_location
+ .job_policies
+ .as_ref()
+ .and_then(|json| serde_json::from_str(json).ok())
+ .unwrap_or_default();
+
+ let location_info = crate::ops::locations::list::LocationInfo {
+ id: updated_location.uuid,
+ path: directory_path.path.clone().into(),
+ name: updated_location.name.clone(),
+ sd_path,
+ job_policies,
+ index_mode: updated_location.index_mode.clone(),
+ scan_state: updated_location.scan_state.clone(),
+ last_scan_at: updated_location.last_scan_at,
+ error_message: updated_location.error_message.clone(),
+ total_file_count: updated_location.total_file_count,
+ total_byte_size: updated_location.total_byte_size,
+ created_at: updated_location.created_at,
+ updated_at: updated_location.updated_at,
+ };
+
+ context.events.emit(crate::infra::event::Event::ResourceChanged {
+ resource_type: "location".to_string(),
+ resource: serde_json::to_value(&location_info)
+ .map_err(|e| ActionError::Internal(format!("Failed to serialize location: {}", e)))?,
+ metadata: None,
+ });
Ok(LocationUpdateOutput { id: self.input.id })
}
diff --git a/packages/interface/src/Explorer.tsx b/packages/interface/src/Explorer.tsx
index 2165c87f3..128f80534 100644
--- a/packages/interface/src/Explorer.tsx
+++ b/packages/interface/src/Explorer.tsx
@@ -42,7 +42,6 @@ export function ExplorerLayout() {
wireMethod: "query:locations.list",
input: null,
resourceType: "location",
- isGlobalList: true,
});
// Get current location if we're on a location route
diff --git a/packages/interface/src/components/Explorer/components/LocationsSection.tsx b/packages/interface/src/components/Explorer/components/LocationsSection.tsx
index a10327003..7f9e0bf0a 100644
--- a/packages/interface/src/components/Explorer/components/LocationsSection.tsx
+++ b/packages/interface/src/components/Explorer/components/LocationsSection.tsx
@@ -18,7 +18,6 @@ export function LocationsSection() {
wireMethod: "query:locations.list",
input: null,
resourceType: "location",
- isGlobalList: true,
});
const locations = locationsQuery.data?.locations || [];
diff --git a/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx b/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx
index f2852947c..38bc98d54 100644
--- a/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx
+++ b/packages/interface/src/components/SpacesSidebar/LocationsGroup.tsx
@@ -16,7 +16,6 @@ export function LocationsGroup({ isCollapsed, onToggle }: LocationsGroupProps) {
wireMethod: "query:locations.list",
input: null, // Unit struct serializes as null, not {}
resourceType: "location",
- isGlobalList: true,
});
const locations = locationsData?.locations ?? [];
diff --git a/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx b/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx
index 5e70574b2..95fa604fb 100644
--- a/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx
+++ b/packages/interface/src/components/SpacesSidebar/VolumesGroup.tsx
@@ -20,7 +20,6 @@ export function VolumesGroup({
wireMethod: "query:volumes.list",
input: { filter },
resourceType: "volume",
- isGlobalList: true,
});
const volumes = volumesData?.volumes || [];
diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useSpaces.ts b/packages/interface/src/components/SpacesSidebar/hooks/useSpaces.ts
index e520faa61..c9280db4d 100644
--- a/packages/interface/src/components/SpacesSidebar/hooks/useSpaces.ts
+++ b/packages/interface/src/components/SpacesSidebar/hooks/useSpaces.ts
@@ -5,7 +5,6 @@ export function useSpaces() {
wireMethod: 'query:spaces.list',
input: null, // Unit struct serializes as null, not {}
resourceType: 'space',
- isGlobalList: true,
});
}
diff --git a/packages/interface/src/inspectors/LocationInspector.tsx b/packages/interface/src/inspectors/LocationInspector.tsx
index 940435366..afd95b520 100644
--- a/packages/interface/src/inspectors/LocationInspector.tsx
+++ b/packages/interface/src/inspectors/LocationInspector.tsx
@@ -1,30 +1,30 @@
import {
- Info,
- Gear,
- Briefcase,
- ClockCounterClockwise,
- HardDrive,
- DotsThree,
- Hash,
- Sparkle,
- Image,
- MagnifyingGlass,
- Trash,
- FunnelX,
- ToggleLeft,
- ToggleRight,
- X,
- Play,
- FilmStrip,
- VideoCamera,
+ Info,
+ Gear,
+ Briefcase,
+ ClockCounterClockwise,
+ HardDrive,
+ DotsThree,
+ Hash,
+ Sparkle,
+ Image,
+ MagnifyingGlass,
+ Trash,
+ FunnelX,
+ ToggleLeft,
+ ToggleRight,
+ X,
+ Play,
+ FilmStrip,
+ VideoCamera,
} from "@phosphor-icons/react";
import { useState } from "react";
import {
- InfoRow,
- Section,
- Divider,
- Tabs,
- TabContent,
+ InfoRow,
+ Section,
+ Divider,
+ Tabs,
+ TabContent,
} from "../components/Inspector";
import clsx from "clsx";
import type { LocationInfo } from "@sd/ts-client/generated/types";
@@ -32,607 +32,691 @@ import { useLibraryMutation } from "../context";
import LocationIcon from "@sd/assets/icons/Location.png";
interface LocationInspectorProps {
- location: LocationInfo;
+ location: LocationInfo;
}
export function LocationInspector({ location }: LocationInspectorProps) {
- const [activeTab, setActiveTab] = useState("overview");
+ const [activeTab, setActiveTab] = useState("overview");
- const tabs = [
- { id: "overview", label: "Overview", icon: Info },
- { id: "indexing", label: "Indexing", icon: Gear },
- { id: "jobs", label: "Jobs", icon: Briefcase },
- { id: "activity", label: "Activity", icon: ClockCounterClockwise },
- { id: "devices", label: "Devices", icon: HardDrive },
- { id: "more", label: "More", icon: DotsThree },
- ];
+ const tabs = [
+ { id: "overview", label: "Overview", icon: Info },
+ { id: "indexing", label: "Indexing", icon: Gear },
+ { id: "jobs", label: "Jobs", icon: Briefcase },
+ { id: "activity", label: "Activity", icon: ClockCounterClockwise },
+ { id: "devices", label: "Devices", icon: HardDrive },
+ { id: "more", label: "More", icon: DotsThree },
+ ];
- return (
- <>
- {/* Tabs */}
-
+ return (
+ <>
+ {/* Tabs */}
+
- {/* Tab Content */}
-
-
-
-
+ {/* Tab Content */}
+
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
+
+
+
-
-
-
-
- >
- );
+
+
+
+
+ >
+ );
}
function OverviewTab({ location }: { location: LocationInfo }) {
- const formatBytes = (bytes: number) => {
- if (bytes === 0) return "0 B";
- const k = 1024;
- const sizes = ["B", "KB", "MB", "GB", "TB"];
- const i = Math.floor(Math.log(bytes) / Math.log(k));
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
- };
+ const rescanLocation = useLibraryMutation("locations.rescan");
- const formatDate = (dateStr: string) => {
- const date = new Date(dateStr);
- return date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- });
- };
+ const formatBytes = (bytes: number) => {
+ if (bytes === 0) return "0 B";
+ const k = 1024;
+ const sizes = ["B", "KB", "MB", "GB", "TB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
+ };
- return (
-
- {/* Location icon */}
-
-
-

-
-
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ };
- {/* Location name */}
-
-
- {location.name || "Unnamed Location"}
-
-
Local Storage
-
+ return (
+
+ {/* Location icon */}
+
+

+
-
+ {/* Location name */}
+
+
+ {location.name || "Unnamed Location"}
+
+
+ Local Storage
+
+
- {/* Details */}
-
-
-
-
-
- {location.last_scan_at && (
-
- )}
-
+
- {/* Index Mode */}
-
+ {/* Details */}
+
+
+
+
+
+ {location.last_scan_at && (
+
+ )}
+
- {/* Quick Actions */}
-
-
-
-
-
-
-
- );
+ {/* Index Mode */}
+
+
+ {/* Quick Actions */}
+
+
+
+
+
+
+
+ );
}
function IndexingTab({ location }: { location: LocationInfo }) {
- const [indexMode, setIndexMode] = useState<"shallow" | "content" | "deep">(
- location.index_mode as "shallow" | "content" | "deep",
- );
- const [ignoreRules, setIgnoreRules] = useState([
- ".git",
- "node_modules",
- "*.tmp",
- ".DS_Store",
- ]);
+ const [indexMode, setIndexMode] = useState<"shallow" | "content" | "deep">(
+ location.index_mode as "shallow" | "content" | "deep",
+ );
+ const [ignoreRules, setIgnoreRules] = useState([
+ ".git",
+ "node_modules",
+ "*.tmp",
+ ".DS_Store",
+ ]);
- return (
-
-
-
- Controls how deeply this location is indexed
-
+ return (
+
+
+
+ Controls how deeply this location is indexed
+
-
- setIndexMode("shallow")}
- />
- setIndexMode("content")}
- />
- setIndexMode("deep")}
- />
-
-
+
+ setIndexMode("shallow")}
+ />
+ setIndexMode("content")}
+ />
+ setIndexMode("deep")}
+ />
+
+
-
-
- Files and folders matching these patterns will be ignored
-
+
+
+ Files and folders matching these patterns will be ignored
+
-
- {ignoreRules.map((pattern, i) => (
- {
- setIgnoreRules(ignoreRules.filter((_, idx) => idx !== i));
- }}
- />
- ))}
-
+
+ {ignoreRules.map((pattern, i) => (
+ {
+ setIgnoreRules(
+ ignoreRules.filter((_, idx) => idx !== i),
+ );
+ }}
+ />
+ ))}
+
-
-
-
- );
+
+
+
+ );
}
function JobsTab({ location }: { location: LocationInfo }) {
- const updateLocation = useLibraryMutation("locations.update");
- const triggerJob = useLibraryMutation("locations.triggerJob");
+ const updateLocation = useLibraryMutation("locations.update");
+ const triggerJob = useLibraryMutation("locations.triggerJob");
- const updatePolicy = async (
- updates: Partial,
- ) => {
- await updateLocation.mutateAsync({
- id: location.id,
- job_policies: {
- ...location.job_policies,
- ...updates,
- },
- });
- };
+ const updatePolicy = async (
+ updates: Partial,
+ ) => {
+ await updateLocation.mutateAsync({
+ id: location.id,
+ job_policies: {
+ ...location.job_policies,
+ ...updates,
+ },
+ });
+ };
- const thumbnails = location.job_policies?.thumbnail?.enabled ?? true;
- const thumbstrips = location.job_policies?.thumbstrip?.enabled ?? true;
- const proxies = location.job_policies?.proxy?.enabled ?? false;
- const ocr = location.job_policies?.ocr?.enabled ?? false;
- const speech = location.job_policies?.speech_to_text?.enabled ?? false;
+ const thumbnails = location.job_policies?.thumbnail?.enabled ?? true;
+ const thumbstrips = location.job_policies?.thumbstrip?.enabled ?? true;
+ const proxies = location.job_policies?.proxy?.enabled ?? false;
+ const ocr = location.job_policies?.ocr?.enabled ?? false;
+ const speech = location.job_policies?.speech_to_text?.enabled ?? false;
- return (
-
-
- Configure which processing jobs run automatically for this location
-
+ return (
+
+
+ Configure which processing jobs run automatically for this
+ location
+
-
-
-
- updatePolicy({
- thumbnail: { ...location.job_policies.thumbnail, enabled },
- })
- }
- onTrigger={() =>
- triggerJob.mutate({
- location_id: location.id,
- job_type: "thumbnail",
- force: false,
- })
- }
- isTriggering={triggerJob.isPending}
- />
-
- updatePolicy({
- thumbstrip: { ...location.job_policies.thumbstrip, enabled },
- })
- }
- onTrigger={() =>
- triggerJob.mutate({
- location_id: location.id,
- job_type: "thumbstrip",
- force: false,
- })
- }
- isTriggering={triggerJob.isPending}
- icon={FilmStrip}
- />
-
- updatePolicy({
- proxy: { ...location.job_policies.proxy, enabled },
- })
- }
- onTrigger={() =>
- triggerJob.mutate({
- location_id: location.id,
- job_type: "proxy",
- force: false,
- })
- }
- isTriggering={triggerJob.isPending}
- icon={VideoCamera}
- />
-
-
+
+
+
+ updatePolicy({
+ thumbnail: {
+ ...location.job_policies.thumbnail,
+ enabled,
+ },
+ })
+ }
+ onTrigger={() =>
+ triggerJob.mutate({
+ location_id: location.id,
+ job_type: "thumbnail",
+ force: false,
+ })
+ }
+ isTriggering={triggerJob.isPending}
+ />
+
+ updatePolicy({
+ thumbstrip: {
+ ...location.job_policies.thumbstrip,
+ enabled,
+ },
+ })
+ }
+ onTrigger={() =>
+ triggerJob.mutate({
+ location_id: location.id,
+ job_type: "thumbstrip",
+ force: false,
+ })
+ }
+ isTriggering={triggerJob.isPending}
+ icon={FilmStrip}
+ />
+
+ updatePolicy({
+ proxy: {
+ ...location.job_policies.proxy,
+ enabled,
+ },
+ })
+ }
+ onTrigger={() =>
+ triggerJob.mutate({
+ location_id: location.id,
+ job_type: "proxy",
+ force: false,
+ })
+ }
+ isTriggering={triggerJob.isPending}
+ icon={VideoCamera}
+ />
+
+
-
-
-
- updatePolicy({ ocr: { ...location.job_policies.ocr, enabled } })
- }
- onTrigger={() =>
- triggerJob.mutate({
- location_id: location.id,
- job_type: "ocr",
- force: false,
- })
- }
- isTriggering={triggerJob.isPending}
- />
-
- updatePolicy({
- speech_to_text: {
- ...location.job_policies.speech_to_text,
- enabled,
- },
- })
- }
- onTrigger={() =>
- triggerJob.mutate({
- location_id: location.id,
- job_type: "speech_to_text",
- force: false,
- })
- }
- isTriggering={triggerJob.isPending}
- />
-
-
-
- );
+
+
+
+ updatePolicy({
+ ocr: { ...location.job_policies.ocr, enabled },
+ })
+ }
+ onTrigger={() =>
+ triggerJob.mutate({
+ location_id: location.id,
+ job_type: "ocr",
+ force: false,
+ })
+ }
+ isTriggering={triggerJob.isPending}
+ />
+
+ updatePolicy({
+ speech_to_text: {
+ ...location.job_policies.speech_to_text,
+ enabled,
+ },
+ })
+ }
+ onTrigger={() =>
+ triggerJob.mutate({
+ location_id: location.id,
+ job_type: "speech_to_text",
+ force: false,
+ })
+ }
+ isTriggering={triggerJob.isPending}
+ />
+
+
+
+ );
}
function ActivityTab({ location }: { location: LocationInfo }) {
- const activity = [
- { action: "Full Scan Completed", time: "10 min ago", files: 12456 },
- { action: "Thumbnails Generated", time: "1 hour ago", files: 234 },
- { action: "Content Hashes Updated", time: "3 hours ago", files: 5678 },
- { action: "Metadata Extracted", time: "5 hours ago", files: 890 },
- { action: "Location Added", time: "Jan 15, 2025", files: 0 },
- ];
+ const activity = [
+ { action: "Full Scan Completed", time: "10 min ago", files: 12456 },
+ { action: "Thumbnails Generated", time: "1 hour ago", files: 234 },
+ { action: "Content Hashes Updated", time: "3 hours ago", files: 5678 },
+ { action: "Metadata Extracted", time: "5 hours ago", files: 890 },
+ { action: "Location Added", time: "Jan 15, 2025", files: 0 },
+ ];
- return (
-
-
- Recent indexing activity and job history
-
+ return (
+
+
+ Recent indexing activity and job history
+
-
- {activity.map((item, i) => (
-
-
-
-
{item.action}
-
- {item.time}
- {item.files > 0 && ` · ${item.files.toLocaleString()} files`}
-
-
-
- ))}
-
-
- );
+
+ {activity.map((item, i) => (
+
+
+
+
+ {item.action}
+
+
+ {item.time}
+ {item.files > 0 &&
+ ` · ${item.files.toLocaleString()} files`}
+
+
+
+ ))}
+
+
+ );
}
function DevicesTab({ location }: { location: LocationInfo }) {
- const devices = [
- { name: "MacBook Pro", status: "online" as const, lastSeen: "2 min ago" },
- { name: "Desktop PC", status: "offline" as const, lastSeen: "2 days ago" },
- { name: "Home Server", status: "online" as const, lastSeen: "5 min ago" },
- ];
+ const devices = [
+ {
+ name: "MacBook Pro",
+ status: "online" as const,
+ lastSeen: "2 min ago",
+ },
+ {
+ name: "Desktop PC",
+ status: "offline" as const,
+ lastSeen: "2 days ago",
+ },
+ {
+ name: "Home Server",
+ status: "online" as const,
+ lastSeen: "5 min ago",
+ },
+ ];
- return (
-
-
- Devices that have access to this location
-
+ return (
+
+
+ Devices that have access to this location
+
-
- {devices.map((device, i) => (
-
-
-
-
-
- {device.name}
-
-
-
-
- {device.status === "online" ? "Online" : "Offline"} ·{" "}
- {device.lastSeen}
-
-
-
-
-
- ))}
-
-
- );
+
+ {devices.map((device, i) => (
+
+
+
+
+
+ {device.name}
+
+
+
+
+ {device.status === "online"
+ ? "Online"
+ : "Offline"}{" "}
+ · {device.lastSeen}
+
+
+
+
+
+ ))}
+
+
+ );
}
function MoreTab({ location }: { location: LocationInfo }) {
- const removeLocation = useLibraryMutation("locations.remove");
+ const removeLocation = useLibraryMutation("locations.remove");
- const formatDate = (dateStr: string) => {
- const date = new Date(dateStr);
- return date.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- });
- };
+ const formatDate = (dateStr: string) => {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ };
- return (
-
-
-
-
- {location.last_scan_at && (
-
- )}
-
+ return (
+
+
+
+
+ {location.last_scan_at && (
+
+ )}
+
-
-
- Removing this location will not delete your files
-
-
-
-
- );
+
+
+ Removing this location will not delete your files
+
+
+
+
+ );
}
// Helper Components
interface RadioOptionProps {
- value: string;
- label: string;
- description: string;
- checked: boolean;
- onChange: () => void;
+ value: string;
+ label: string;
+ description: string;
+ checked: boolean;
+ onChange: () => void;
}
function RadioOption({
- value,
- label,
- description,
- checked,
- onChange,
+ value,
+ label,
+ description,
+ checked,
+ onChange,
}: RadioOptionProps) {
- return (
-
- );
+ return (
+
+ );
}
interface IgnoreRuleProps {
- pattern: string;
- onRemove: () => void;
+ pattern: string;
+ onRemove: () => void;
}
function IgnoreRule({ pattern, onRemove }: IgnoreRuleProps) {
- return (
-
-
- {pattern}
-
-
-
- );
+ return (
+
+
+ {pattern}
+
+
+
+ );
}
interface JobConfigRowProps {
- label: string;
- description: string;
- enabled: boolean;
- onToggle: (enabled: boolean) => void;
- onTrigger: () => void;
- isTriggering: boolean;
- icon?: React.ComponentType;
+ label: string;
+ description: string;
+ enabled: boolean;
+ onToggle: (enabled: boolean) => void;
+ onTrigger: () => void;
+ isTriggering: boolean;
+ icon?: React.ComponentType;
}
function JobConfigRow({
- label,
- description,
- enabled,
- onToggle,
- onTrigger,
- isTriggering,
- icon: Icon,
+ label,
+ description,
+ enabled,
+ onToggle,
+ onTrigger,
+ isTriggering,
+ icon: Icon,
}: JobConfigRowProps) {
- return (
-
-
-
-
- );
+ return (
+
+
+
+
+ );
}
diff --git a/packages/interface/src/routes/overview/StorageOverview.tsx b/packages/interface/src/routes/overview/StorageOverview.tsx
index 5fcda99e4..9dd3dd610 100644
--- a/packages/interface/src/routes/overview/StorageOverview.tsx
+++ b/packages/interface/src/routes/overview/StorageOverview.tsx
@@ -1,5 +1,5 @@
import { motion } from "framer-motion";
-import { HardDrive } from "@phosphor-icons/react";
+import { HardDrive, Plus } from "@phosphor-icons/react";
import DriveIcon from "@sd/assets/icons/Drive.png";
import HDDIcon from "@sd/assets/icons/HDD.png";
import ServerIcon from "@sd/assets/icons/Server.png";
@@ -7,7 +7,7 @@ import DatabaseIcon from "@sd/assets/icons/Database.png";
import DriveAmazonS3Icon from "@sd/assets/icons/Drive-AmazonS3.png";
import DriveGoogleDriveIcon from "@sd/assets/icons/Drive-GoogleDrive.png";
import DriveDropboxIcon from "@sd/assets/icons/Drive-Dropbox.png";
-import { useNormalizedCache } from "../../context";
+import { useNormalizedCache, useLibraryMutation } from "../../context";
import type {
VolumeListOutput,
VolumeListQueryInput,
@@ -65,7 +65,6 @@ export function StorageOverview() {
wireMethod: "query:volumes.list",
input: { filter: "All" },
resourceType: "volume",
- isGlobalList: true,
});
// Fetch all devices using normalized cache
@@ -76,7 +75,6 @@ export function StorageOverview() {
wireMethod: "query:devices.list",
input: { include_offline: true, include_details: false },
resourceType: "device",
- isGlobalList: true,
});
if (volumesLoading || devicesLoading) {
@@ -189,6 +187,18 @@ function getDummyVolumeStats(volumeName: string) {
}
function VolumeBar({ volume, index }: VolumeBarProps) {
+ const trackVolume = useLibraryMutation("volumes.track");
+
+ const handleTrack = async () => {
+ try {
+ await trackVolume.mutateAsync({
+ fingerprint: volume.fingerprint,
+ });
+ } catch (error) {
+ console.error("Failed to track volume:", error);
+ }
+ };
+
// Use real data from backend, fallback to dummy data if not available
const useDummyData = !volume.total_capacity;
const dummy = useDummyData ? getDummyVolumeStats(volume.name) : null;
@@ -244,9 +254,15 @@ function VolumeBar({ volume, index }: VolumeBarProps) {
)}
{!volume.is_tracked && (
-
- Untracked
-
+
)}
{useDummyData && (
diff --git a/packages/ts-client/src/hooks/useNormalizedCache.ts b/packages/ts-client/src/hooks/useNormalizedCache.ts
index f61123db3..263a4ccf2 100644
--- a/packages/ts-client/src/hooks/useNormalizedCache.ts
+++ b/packages/ts-client/src/hooks/useNormalizedCache.ts
@@ -11,55 +11,61 @@ import { useSpacedriveClient } from "./useClient";
* @param noMergeFields - Fields to replace (from Identifiable.no_merge_fields)
*/
function deepMerge(
- existing: any,
- incoming: any,
- noMergeFields: string[] = [],
+ existing: any,
+ incoming: any,
+ noMergeFields: string[] = [],
): any {
- // If incoming is null/undefined, keep existing
- if (incoming === null || incoming === undefined) {
- return existing !== null && existing !== undefined ? existing : incoming;
- }
+ // If incoming is null/undefined, keep existing
+ if (incoming === null || incoming === undefined) {
+ return existing !== null && existing !== undefined
+ ? existing
+ : incoming;
+ }
- // If types don't match or not objects, incoming wins
- if (
- typeof existing !== "object" ||
- typeof incoming !== "object" ||
- Array.isArray(existing) ||
- Array.isArray(incoming)
- ) {
- return incoming;
- }
+ // If types don't match or not objects, incoming wins
+ if (
+ typeof existing !== "object" ||
+ typeof incoming !== "object" ||
+ Array.isArray(existing) ||
+ Array.isArray(incoming)
+ ) {
+ return incoming;
+ }
- // Both are objects - deep merge
- const merged: any = { ...incoming };
+ // Both are objects - deep merge
+ const merged: any = { ...incoming };
- for (const key in existing) {
- // Check if this field should not be merged (from backend Identifiable trait)
- if (noMergeFields.includes(key)) {
- continue; // Use incoming value as-is
- }
+ for (const key in existing) {
+ // Check if this field should not be merged (from backend Identifiable trait)
+ if (noMergeFields.includes(key)) {
+ continue; // Use incoming value as-is
+ }
- if (!(key in incoming)) {
- // Key exists in old but not new - preserve it
- merged[key] = existing[key];
- } else if (incoming[key] === null || incoming[key] === undefined) {
- // Key exists in both but new is null - preserve old
- if (existing[key] !== null && existing[key] !== undefined) {
- merged[key] = existing[key];
- }
- } else if (
- typeof existing[key] === "object" &&
- typeof incoming[key] === "object" &&
- !Array.isArray(existing[key]) &&
- !Array.isArray(incoming[key])
- ) {
- // Both are objects - recurse
- merged[key] = deepMerge(existing[key], incoming[key], noMergeFields);
- }
- // else: incoming wins (has non-null value)
- }
+ if (!(key in incoming)) {
+ // Key exists in old but not new - preserve it
+ merged[key] = existing[key];
+ } else if (incoming[key] === null || incoming[key] === undefined) {
+ // Key exists in both but new is null - preserve old
+ if (existing[key] !== null && existing[key] !== undefined) {
+ merged[key] = existing[key];
+ }
+ } else if (
+ typeof existing[key] === "object" &&
+ typeof incoming[key] === "object" &&
+ !Array.isArray(existing[key]) &&
+ !Array.isArray(incoming[key])
+ ) {
+ // Both are objects - recurse
+ merged[key] = deepMerge(
+ existing[key],
+ incoming[key],
+ noMergeFields,
+ );
+ }
+ // else: incoming wins (has non-null value)
+ }
- return merged;
+ return merged;
}
/**
@@ -67,49 +73,51 @@ function deepMerge(
* Uses metadata from Identifiable trait for matching
*/
function resourceMatches(
- existing: any,
- incoming: any,
- alternateIds: string[] = [],
+ existing: any,
+ incoming: any,
+ alternateIds: string[] = [],
): boolean {
- // Match by primary ID
- if (existing.id === incoming.id) {
- return true;
- }
+ // Match by primary ID
+ if (existing.id === incoming.id) {
+ return true;
+ }
- // Match by any alternate ID (e.g., content UUID for Files)
- for (const altId of alternateIds) {
- if (existing.id === altId || incoming.id === altId) {
- return true;
- }
- }
+ // Match by any alternate ID (e.g., content UUID for Files)
+ for (const altId of alternateIds) {
+ if (existing.id === altId || incoming.id === altId) {
+ return true;
+ }
+ }
- return false;
+ return false;
}
interface UseNormalizedCacheOptions {
- /** Wire method to call (e.g., "query:locations.list") */
- wireMethod: string;
- /** Input for the query */
- input: I;
- /** Resource type for cache indexing (e.g., "location") */
- resourceType: string;
- /** Whether the query is enabled (default: true) */
- enabled?: boolean;
- /** Whether this is a global list query that should accept new items (default: false) */
- isGlobalList?: boolean;
- /** Optional filter function to check if a resource belongs in this query */
- resourceFilter?: (resource: any) => boolean;
- /** Resource ID for single-resource queries (filters events to matching ID only) */
- resourceId?: string;
- /**
- * Optional path scope for filtering events to a specific directory/path.
- * When provided, the backend includes affected_paths in event metadata for efficient filtering.
- *
- * Note: Full server-side filtering is available via EventFilter.path_scope in the daemon,
- * but current client architecture uses a single global subscription. Future enhancement
- * could create separate filtered subscriptions per hook.
- */
- pathScope?: import("../types").SdPath;
+ /** Wire method to call (e.g., "query:locations.list") */
+ wireMethod: string;
+ /** Input for the query */
+ input: I;
+ /** Resource type for cache indexing (e.g., "location") */
+ resourceType: string;
+ /** Whether the query is enabled (default: true) */
+ enabled?: boolean;
+ /**
+ * Optional filter function to check if a resource belongs in this query.
+ * If not provided, all resources that pass the pathScope filter will be added (global list behavior).
+ * Use this for additional filtering beyond path scope (e.g., file type, tags, etc.)
+ */
+ resourceFilter?: (resource: any) => boolean;
+ /** Resource ID for single-resource queries (filters events to matching ID only) */
+ resourceId?: string;
+ /**
+ * Optional path scope for filtering events to a specific directory/path.
+ * When provided, the backend includes affected_paths in event metadata for efficient filtering.
+ *
+ * Note: Full server-side filtering is available via EventFilter.path_scope in the daemon,
+ * but current client architecture uses a single global subscription. Future enhancement
+ * could create separate filtered subscriptions per hook.
+ */
+ pathScope?: import("../types").SdPath;
}
/**
@@ -141,48 +149,52 @@ interface UseNormalizedCacheOptions {
* ```
*/
export function useNormalizedCache({
- wireMethod,
- input,
- resourceType,
- enabled = true,
- isGlobalList = false,
- resourceFilter,
- resourceId,
- pathScope,
+ wireMethod,
+ input,
+ resourceType,
+ enabled = true,
+ resourceFilter,
+ resourceId,
+ pathScope,
}: UseNormalizedCacheOptions) {
- const client = useSpacedriveClient();
- const queryClient = useQueryClient();
+ const client = useSpacedriveClient();
+ const queryClient = useQueryClient();
- // Track library ID reactively so queryKey updates when it changes
- const [libraryId, setLibraryId] = useState(client.getCurrentLibraryId());
+ // Track library ID reactively so queryKey updates when it changes
+ const [libraryId, setLibraryId] = useState(
+ client.getCurrentLibraryId(),
+ );
- // Listen for library ID changes and update our state (causes re-render)
- useEffect(() => {
- const handleLibraryChange = (newLibraryId: string) => {
- setLibraryId(newLibraryId);
- };
+ // Listen for library ID changes and update our state (causes re-render)
+ useEffect(() => {
+ const handleLibraryChange = (newLibraryId: string) => {
+ setLibraryId(newLibraryId);
+ };
- client.on("library-changed", handleLibraryChange);
- return () => {
- client.off("library-changed", handleLibraryChange);
- };
- }, [client, wireMethod]);
+ client.on("library-changed", handleLibraryChange);
+ return () => {
+ client.off("library-changed", handleLibraryChange);
+ };
+ }, [client, wireMethod]);
- // Include library ID in key so switching libraries triggers refetch
- // useMemo to prevent array recreation on every render
- const queryKey = useMemo(() => [wireMethod, libraryId, input], [wireMethod, libraryId, JSON.stringify(input)]);
+ // Include library ID in key so switching libraries triggers refetch
+ // useMemo to prevent array recreation on every render
+ const queryKey = useMemo(
+ () => [wireMethod, libraryId, input],
+ [wireMethod, libraryId, JSON.stringify(input)],
+ );
- // Use TanStack Query normally
- // When libraryId changes, queryKey changes, and TanStack Query automatically fetches new data
- const query = useQuery({
- queryKey,
- queryFn: async () => {
- // Client.execute() automatically adds library_id to the request
- // as a sibling field to payload
- return await client.execute(wireMethod, input);
- },
- enabled: enabled && !!libraryId,
- });
+ // Use TanStack Query normally
+ // When libraryId changes, queryKey changes, and TanStack Query automatically fetches new data
+ const query = useQuery({
+ queryKey,
+ queryFn: async () => {
+ // Client.execute() automatically adds library_id to the request
+ // as a sibling field to payload
+ return await client.execute(wireMethod, input);
+ },
+ enabled: enabled && !!libraryId,
+ });
// Listen for ResourceChanged events and update cache atomically
useEffect(() => {
@@ -195,16 +207,98 @@ export function useNormalizedCache({
// Check if any affected path matches our pathScope
return affectedPaths.some((affectedPath: any) => {
- // For now, do a simple JSON comparison
- // In the future, could use more sophisticated path matching
- return JSON.stringify(affectedPath) === JSON.stringify(pathScope);
+ // Handle Physical paths with hierarchy
+ if ("Physical" in pathScope && "Physical" in affectedPath) {
+ // Handle both device_id (manual types) and device_slug (generated types)
+ const scopeDevice =
+ (pathScope.Physical as any).device_slug ||
+ (pathScope.Physical as any).device_id;
+ const scopePath = (pathScope.Physical as any).path;
+ const fileDevice =
+ (affectedPath.Physical as any).device_slug ||
+ (affectedPath.Physical as any).device_id;
+ const filePath = (affectedPath.Physical as any).path;
+
+ // Must be same device AND file must be under scope directory
+ return (
+ scopeDevice === fileDevice &&
+ filePath.startsWith(scopePath)
+ );
+ }
+
+ // Handle Content ID paths
+ if ("Content" in pathScope && "Content" in affectedPath) {
+ const scope = pathScope as {
+ Content: { content_id: string };
+ };
+ const affected = affectedPath as {
+ Content: { content_id: string };
+ };
+ return (
+ scope.Content.content_id === affected.Content.content_id
+ );
+ }
+
+ // Handle Sidecar paths (match by content ID)
+ if ("Content" in pathScope && "Sidecar" in affectedPath) {
+ const scope = pathScope as {
+ Content: { content_id: string };
+ };
+ const affected = affectedPath as {
+ Sidecar: { content_id: string };
+ };
+ return (
+ scope.Content.content_id === affected.Sidecar.content_id
+ );
+ }
+ if ("Sidecar" in pathScope && "Content" in affectedPath) {
+ const scope = pathScope as {
+ Sidecar: { content_id: string };
+ };
+ const affected = affectedPath as {
+ Content: { content_id: string };
+ };
+ return (
+ scope.Sidecar.content_id === affected.Content.content_id
+ );
+ }
+
+ // Handle Cloud paths
+ if ("Cloud" in pathScope && "Cloud" in affectedPath) {
+ const scope = pathScope as {
+ Cloud: {
+ service: string;
+ identifier: string;
+ path: string;
+ };
+ };
+ const affected = affectedPath as {
+ Cloud: {
+ service: string;
+ identifier: string;
+ path: string;
+ };
+ };
+ return (
+ scope.Cloud.service === affected.Cloud.service &&
+ scope.Cloud.identifier === affected.Cloud.identifier &&
+ affected.Cloud.path.startsWith(scope.Cloud.path)
+ );
+ }
+
+ // Fallback to exact match for unknown types
+ return (
+ JSON.stringify(affectedPath) === JSON.stringify(pathScope)
+ );
});
};
const handleEvent = (event: any) => {
// Handle Refresh event - invalidate all queries
if ("Refresh" in event) {
- console.log("[useNormalizedCache] Refresh event received, invalidating all queries");
+ console.log(
+ "[useNormalizedCache] Refresh event received, invalidating all queries",
+ );
queryClient.invalidateQueries();
return;
}
@@ -214,450 +308,536 @@ export function useNormalizedCache({
return;
}
- // Check if this is a ResourceChanged event for our resource type
- if ("ResourceChanged" in event) {
- const { resource_type, resource, metadata } = event.ResourceChanged;
+ // Check if this is a ResourceChanged event for our resource type
+ if ("ResourceChanged" in event) {
+ const { resource_type, resource, metadata } =
+ event.ResourceChanged;
- const noMergeFields = metadata?.no_merge_fields || [];
+ const noMergeFields = metadata?.no_merge_fields || [];
- if (resource_type === resourceType && eventAffectsPath(metadata)) {
- // Atomic update: merge this resource into the query data
- queryClient.setQueryData(queryKey, (oldData) => {
- if (!oldData) {
- return oldData;
- }
+ // Log all events that match our resource type
+ if (resource_type === resourceType)
+ console.log(
+ "targeted ResourceChanged event",
+ resource_type,
+ resourceType,
+ event,
+ );
- // Handle both array responses and wrapped responses
- // e.g., LocationsListOutput = { locations: LocationInfo[] }
- if (Array.isArray(oldData)) {
- // Direct array response
- const resourceId = resource.id;
- const existingIndex = oldData.findIndex(
- (item: any) => item.id === resourceId,
- );
+ if (
+ resource_type === resourceType &&
+ eventAffectsPath(metadata)
+ ) {
+ console.log("ResourceChanged event affects path", metadata);
+ // Atomic update: merge this resource into the query data
+ queryClient.setQueryData(queryKey, (oldData) => {
+ if (!oldData) {
+ return oldData;
+ }
- if (existingIndex >= 0) {
- const newData = [...oldData];
- newData[existingIndex] = deepMerge(
- oldData[existingIndex],
- resource,
- noMergeFields,
- );
- return newData as O;
- }
+ // Handle both array responses and wrapped responses
+ // e.g., LocationsListOutput = { locations: LocationInfo[] }
+ if (Array.isArray(oldData)) {
+ // Direct array response
+ const resourceId = resource.id;
+ const existingIndex = oldData.findIndex(
+ (item: any) => item.id === resourceId,
+ );
- // Append if this is a global list OR resource passes filter
- if (
- isGlobalList ||
- (resourceFilter && resourceFilter(resource))
- ) {
- console.log(
- "[Cache] Appending new item to array (isGlobalList:",
- isGlobalList,
- ")",
- );
- return [...oldData, resource] as O;
- }
+ if (existingIndex >= 0) {
+ const newData = [...oldData];
+ newData[existingIndex] = deepMerge(
+ oldData[existingIndex],
+ resource,
+ noMergeFields,
+ );
+ return newData as O;
+ }
- console.log("[Cache] ️ Skipping - not in list scope");
+ // Append if no filter OR resource passes filter
+ if (!resourceFilter || resourceFilter(resource)) {
+ console.log(
+ "[Cache] Appending new item to array",
+ );
+ return [...oldData, resource] as O;
+ }
- return oldData;
- } else if (oldData && typeof oldData === "object") {
- // Wrapped response - look for array field
- // Try common wrapper field names
- const arrayField = Object.keys(oldData).find((key) =>
- Array.isArray((oldData as any)[key]),
- );
+ console.log(
+ "[Cache] Skipping - filtered out by resourceFilter",
+ );
- if (arrayField) {
- const array = (oldData as any)[arrayField];
- const resourceId = resource.id;
- const existingIndex = array.findIndex(
- (item: any) => item.id === resourceId,
- );
+ return oldData;
+ } else if (oldData && typeof oldData === "object") {
+ // Wrapped response - look for array field
+ // Try common wrapper field names
+ const arrayField = Object.keys(oldData).find(
+ (key) => Array.isArray((oldData as any)[key]),
+ );
- if (existingIndex >= 0) {
- const newArray = [...array];
- newArray[existingIndex] = deepMerge(
- array[existingIndex],
- resource,
- noMergeFields,
- );
- console.log(`[${resource_type}] Updated existing item in wrapped array`, { wireMethod, field: arrayField, id: resource.id });
- return { ...oldData, [arrayField]: newArray };
- }
+ if (arrayField) {
+ const array = (oldData as any)[arrayField];
+ const resourceId = resource.id;
+ const existingIndex = array.findIndex(
+ (item: any) => item.id === resourceId,
+ );
- // Append if this is a global list OR resource passes filter
- if (
- isGlobalList ||
- (resourceFilter && resourceFilter(resource))
- ) {
- console.log(`[${resource_type}] Appended to wrapped array`, { wireMethod, field: arrayField, id: resource.id });
- return { ...oldData, [arrayField]: [...array, resource] };
- }
+ if (existingIndex >= 0) {
+ const newArray = [...array];
+ newArray[existingIndex] = deepMerge(
+ array[existingIndex],
+ resource,
+ noMergeFields,
+ );
+ console.log(
+ `[${resource_type}] Updated existing item in wrapped array`,
+ {
+ wireMethod,
+ field: arrayField,
+ id: resource.id,
+ },
+ );
+ return {
+ ...oldData,
+ [arrayField]: newArray,
+ };
+ }
- return oldData;
- }
+ // Append if no filter OR resource passes filter
+ if (
+ !resourceFilter ||
+ resourceFilter(resource)
+ ) {
+ console.log(
+ `[${resource_type}] Appended to wrapped array`,
+ {
+ wireMethod,
+ field: arrayField,
+ id: resource.id,
+ },
+ );
+ return {
+ ...oldData,
+ [arrayField]: [...array, resource],
+ };
+ }
- // Check for wrapped single-object field (e.g., { layout: SpaceLayout })
- for (const key of Object.keys(oldData)) {
- const wrappedValue = (oldData as any)[key];
- if (
- wrappedValue &&
- typeof wrappedValue === "object" &&
- !Array.isArray(wrappedValue) &&
- wrappedValue.id === resource.id
- ) {
- console.log(`[${resource_type}] Updated wrapped object`, { wireMethod, field: key, id: resource.id });
- return {
- ...oldData,
- [key]: deepMerge(wrappedValue, resource, noMergeFields),
- } as O;
- }
- }
+ return oldData;
+ }
- // Handle single object response (e.g., files.by_id returns a single File)
- // Check if oldData is a single resource object
- if ((oldData as any).id === resource.id) {
- // This is the file we're displaying - merge the update
- // console.log('[Cache] Updating single resource:', {
- // oldId: (oldData as any).id,
- // newId: resource.id,
- // name: resource.name,
- // });
- return deepMerge(oldData, resource, noMergeFields) as O;
- }
+ // Check for wrapped single-object field (e.g., { layout: SpaceLayout })
+ for (const key of Object.keys(oldData)) {
+ const wrappedValue = (oldData as any)[key];
+ if (
+ wrappedValue &&
+ typeof wrappedValue === "object" &&
+ !Array.isArray(wrappedValue) &&
+ wrappedValue.id === resource.id
+ ) {
+ console.log(
+ `[${resource_type}] Updated wrapped object`,
+ {
+ wireMethod,
+ field: key,
+ id: resource.id,
+ },
+ );
+ return {
+ ...oldData,
+ [key]: deepMerge(
+ wrappedValue,
+ resource,
+ noMergeFields,
+ ),
+ } as O;
+ }
+ }
- // Also check by content UUID for single object
- if (
- (oldData as any).content_identity?.uuid &&
- (oldData as any).content_identity.uuid ===
- resource.content_identity?.uuid
- ) {
- // console.log('[Cache] Updating single resource by content UUID:', {
- // contentId: resource.content_identity.uuid,
- // name: resource.name,
- // });
- return deepMerge(oldData, resource, noMergeFields) as O;
- }
- }
+ // Handle single object response (e.g., files.by_id returns a single File)
+ // Check if oldData is a single resource object
+ if ((oldData as any).id === resource.id) {
+ // This is the file we're displaying - merge the update
+ // console.log('[Cache] Updating single resource:', {
+ // oldId: (oldData as any).id,
+ // newId: resource.id,
+ // name: resource.name,
+ // });
+ return deepMerge(
+ oldData,
+ resource,
+ noMergeFields,
+ ) as O;
+ }
- return oldData;
- });
- }
- } else if ("ResourceChangedBatch" in event) {
- const { resource_type, resources, metadata } =
- event.ResourceChangedBatch;
+ // Also check by content UUID for single object
+ if (
+ (oldData as any).content_identity?.uuid &&
+ (oldData as any).content_identity.uuid ===
+ resource.content_identity?.uuid
+ ) {
+ // console.log('[Cache] Updating single resource by content UUID:', {
+ // contentId: resource.content_identity.uuid,
+ // name: resource.name,
+ // });
+ return deepMerge(
+ oldData,
+ resource,
+ noMergeFields,
+ ) as O;
+ }
+ }
- // console.log("[ResourceEvent] ResourceChangedBatch:", {
- // resourceType: resource_type,
- // ourType: resourceType,
- // wireMethod,
- // isArray: Array.isArray(resources),
- // count: Array.isArray(resources) ? resources.length : "not array",
- // firstItem: Array.isArray(resources) ? resources[0] : resources,
- // });
+ return oldData;
+ });
+ }
+ } else if ("ResourceChangedBatch" in event) {
+ const { resource_type, resources, metadata } =
+ event.ResourceChangedBatch;
- if (resource_type === resourceType && Array.isArray(resources) && eventAffectsPath(metadata)) {
- // Filter to matching resourceId if specified (for single-resource queries)
- const filteredResources = resourceId
- ? resources.filter((r: any) => r.id === resourceId)
- : resources;
+ // Log all batch events that match our resource type
+ if (resource_type === resourceType) {
+ console.log(
+ "targeted ResourceChangedBatch event",
+ resource_type,
+ resourceType,
+ "passes path filter:",
+ eventAffectsPath(metadata),
+ metadata,
+ );
+ }
- if (filteredResources.length === 0) {
- return; // No matching resources for this query
- }
- // Extract merge config from Identifiable metadata
- const noMergeFields = metadata?.no_merge_fields || [];
- const alternateIds = metadata?.alternate_ids || [];
+ if (
+ resource_type === resourceType &&
+ Array.isArray(resources) &&
+ eventAffectsPath(metadata)
+ ) {
+ // Filter to matching resourceId if specified (for single-resource queries)
+ const filteredResources = resourceId
+ ? resources.filter((r: any) => r.id === resourceId)
+ : resources;
- // Atomic update: merge filtered resources into the query data
- queryClient.setQueryData(queryKey, (oldData) => {
- if (!oldData) return oldData;
+ if (filteredResources.length === 0) {
+ return; // No matching resources for this query
+ }
+ // Extract merge config from Identifiable metadata
+ const noMergeFields = metadata?.no_merge_fields || [];
+ const alternateIds = metadata?.alternate_ids || [];
- // Helper: check if resource matches by ID or alternate IDs
- const matches = (existing: any, incoming: any) => {
- if (existing.id === incoming.id) return true;
- // Check alternate IDs (e.g., content UUID for Files)
- return alternateIds.some(
- (altId) =>
- existing.id === altId ||
- existing.content_identity?.uuid === altId ||
- incoming.id === altId ||
- incoming.content_identity?.uuid === altId,
- );
- };
+ // Atomic update: merge filtered resources into the query data
+ queryClient.setQueryData(queryKey, (oldData) => {
+ if (!oldData) return oldData;
- // Create a map of filtered incoming resources
- const resourceMap = new Map(filteredResources.map((r: any) => [r.id, r]));
+ // Helper: check if resource matches by ID or alternate IDs
+ const matches = (existing: any, incoming: any) => {
+ if (existing.id === incoming.id) return true;
+ // Check alternate IDs (e.g., content UUID for Files)
+ return alternateIds.some(
+ (altId: any) =>
+ existing.id === altId ||
+ existing.content_identity?.uuid === altId ||
+ incoming.id === altId ||
+ incoming.content_identity?.uuid === altId,
+ );
+ };
- if (Array.isArray(oldData)) {
- // Direct array response
- const newData = [...oldData];
- const seenIds = new Set();
+ // Create a map of filtered incoming resources
+ const resourceMap = new Map(
+ filteredResources.map((r: any) => [r.id, r]),
+ );
- // Update existing items with deep merge
- for (let i = 0; i < newData.length; i++) {
- const item: any = newData[i];
- if (resourceMap.has(item.id)) {
- const incomingResource = resourceMap.get(item.id);
- newData[i] = deepMerge(item, incomingResource);
- seenIds.add(item.id);
- }
- }
+ if (Array.isArray(oldData)) {
+ // Direct array response
+ const newData = [...oldData];
+ const seenIds = new Set();
- // Append new items if:
- // - This is a global list query, OR
- // - The resource passes the filter (belongs in this query scope)
- if (isGlobalList) {
- for (const resource of resources) {
- if (!seenIds.has(resource.id)) {
- newData.push(resource);
- }
- }
- } else if (resourceFilter) {
- for (const resource of resources) {
- if (seenIds.has(resource.id)) {
- continue; // Already updated by ID
- }
+ // Update existing items with deep merge
+ for (let i = 0; i < newData.length; i++) {
+ const item: any = newData[i];
+ if (resourceMap.has(item.id)) {
+ const incomingResource = resourceMap.get(
+ item.id,
+ );
+ newData[i] = deepMerge(
+ item,
+ incomingResource,
+ );
+ seenIds.add(item.id);
+ }
+ }
- // Check if we should process this resource
- const shouldAppend = resourceFilter(resource);
- if (!shouldAppend) {
- continue;
- }
+ // Append new items if no filter OR resource passes filter
+ for (const resource of resources) {
+ if (seenIds.has(resource.id)) {
+ continue; // Already updated by ID
+ }
- // For Content-based paths with multiple entries, update by content UUID
- // (sidecar events can create multiple File resources for the same content)
- if (
- resource.sd_path?.Content &&
- resource.content_identity?.uuid
- ) {
- const contentId = resource.content_identity.uuid;
+ // Check if we should process this resource
+ if (
+ resourceFilter &&
+ !resourceFilter(resource)
+ ) {
+ continue; // Filtered out
+ }
- // Find existing item with same content
- const existingIndex = newData.findIndex(
- (item: any) => item.content_identity?.uuid === contentId,
- );
+ // For Content-based paths with multiple entries, update by content UUID
+ // (sidecar events can create multiple File resources for the same content)
+ if (
+ resource.sd_path?.Content &&
+ resource.content_identity?.uuid
+ ) {
+ const contentId =
+ resource.content_identity.uuid;
- if (existingIndex >= 0) {
- // Update existing item (merge sidecars, etc.)
- newData[existingIndex] = deepMerge(
- newData[existingIndex],
- resource,
- noMergeFields,
- );
- console.log(
- "[Cache] Updated existing file by content UUID:",
- {
- name: resource.name,
- contentId,
- },
- );
- continue;
- }
- }
+ // Find existing item with same content
+ const existingIndex = newData.findIndex(
+ (item: any) =>
+ item.content_identity?.uuid ===
+ contentId,
+ );
- // New item - append it
- newData.push(resource);
- }
- }
+ if (existingIndex >= 0) {
+ // Update existing item (merge sidecars, etc.)
+ newData[existingIndex] = deepMerge(
+ newData[existingIndex],
+ resource,
+ noMergeFields,
+ );
+ console.log(
+ "[Cache] Updated existing file by content UUID:",
+ {
+ name: resource.name,
+ contentId,
+ },
+ );
+ continue;
+ }
+ }
- return newData as O;
- } else if (oldData && typeof oldData === "object") {
- // Check if this is a single resource object vs wrapper
- // Single resource has: id field
- // Wrapper has: array field (files, locations, etc.) + pagination fields
- const isSingleResource = !!(oldData as any).id;
+ // New item - append it
+ newData.push(resource);
+ }
- // console.log("[Cache] Batch - response type check:", {
- // isSingleResource,
- // hasId: !!(oldData as any).id,
- // hasSdPath: !!(oldData as any).sd_path,
- // firstKey: Object.keys(oldData)[0],
- // });
+ return newData as O;
+ } else if (oldData && typeof oldData === "object") {
+ // Check if this is a single resource object vs wrapper
+ // Single resource has: id field
+ // Wrapper has: array field (files, locations, etc.) + pagination fields
+ const isSingleResource = !!(oldData as any).id;
- if (isSingleResource) {
- // For File resources with sd_path, validate path matches (prevent cross-path pollution)
- const oldPath = (oldData as any).sd_path;
+ // console.log("[Cache] Batch - response type check:", {
+ // isSingleResource,
+ // hasId: !!(oldData as any).id,
+ // hasSdPath: !!(oldData as any).sd_path,
+ // firstKey: Object.keys(oldData)[0],
+ // });
- if (oldPath) {
- // This is a File with a path - filter to matching path only
- const filteredByPath = filteredResources.filter((resource: any) => {
- if (!resource.sd_path) return false;
+ if (isSingleResource) {
+ // For File resources with sd_path, validate path matches (prevent cross-path pollution)
+ const oldPath = (oldData as any).sd_path;
- // Deep compare sd_path objects
- return JSON.stringify(oldPath) === JSON.stringify(resource.sd_path);
- });
+ if (oldPath) {
+ // This is a File with a path - filter to matching path only
+ const filteredByPath =
+ filteredResources.filter(
+ (resource: any) => {
+ if (!resource.sd_path)
+ return false;
- if (filteredByPath.length === 0) {
- return oldData; // No matching paths, don't update
- }
+ // Deep compare sd_path objects
+ return (
+ JSON.stringify(oldPath) ===
+ JSON.stringify(
+ resource.sd_path,
+ )
+ );
+ },
+ );
- // Update to only process path-matching resources
- filteredResources.length = 0;
- filteredResources.push(...filteredByPath);
- resourceMap.clear();
- filteredByPath.forEach(r => resourceMap.set(r.id, r));
- }
+ if (filteredByPath.length === 0) {
+ return oldData; // No matching paths, don't update
+ }
- // For non-File resources (SpaceLayout, etc), no path filtering needed
- // They're already filtered by resourceId above
+ // Update to only process path-matching resources
+ filteredResources.length = 0;
+ filteredResources.push(...filteredByPath);
+ resourceMap.clear();
+ filteredByPath.forEach((r) =>
+ resourceMap.set(r.id, r),
+ );
+ }
- // Single object response - check each incoming resource
- for (const resource of filteredResources) {
- // Match by ID
- if ((oldData as any).id === resource.id) {
- console.log("[Cache] ✓ Updating single object by ID:", {
- name: resource.name,
- id: resource.id,
- });
- return deepMerge(oldData, resource, noMergeFields) as O;
- }
+ // For non-File resources (SpaceLayout, etc), no path filtering needed
+ // They're already filtered by resourceId above
- // Match by content UUID
- if (
- (oldData as any).content_identity?.uuid &&
- (oldData as any).content_identity.uuid ===
- resource.content_identity?.uuid
- ) {
- console.log(
- "[Cache] ✓ Updating single object by content UUID:",
- {
- name: resource.name,
- contentId: resource.content_identity.uuid,
- },
- );
- return deepMerge(oldData, resource, noMergeFields) as O;
- }
- }
+ // Single object response - check each incoming resource
+ for (const resource of filteredResources) {
+ // Match by ID
+ if ((oldData as any).id === resource.id) {
+ console.log(
+ "[Cache] ✓ Updating single object by ID:",
+ {
+ name: resource.name,
+ id: resource.id,
+ },
+ );
+ return deepMerge(
+ oldData,
+ resource,
+ noMergeFields,
+ ) as O;
+ }
- console.log("[Cache] ✗ No match found for single object");
- // No match - return unchanged
- return oldData;
- }
+ // Match by content UUID
+ if (
+ (oldData as any).content_identity
+ ?.uuid &&
+ (oldData as any).content_identity
+ .uuid ===
+ resource.content_identity?.uuid
+ ) {
+ console.log(
+ "[Cache] ✓ Updating single object by content UUID:",
+ {
+ name: resource.name,
+ contentId:
+ resource.content_identity
+ .uuid,
+ },
+ );
+ return deepMerge(
+ oldData,
+ resource,
+ noMergeFields,
+ ) as O;
+ }
+ }
- // Wrapped response with array field
- const arrayField = Object.keys(oldData).find((key) =>
- Array.isArray((oldData as any)[key]),
- );
+ console.log(
+ "[Cache] ✗ No match found for single object",
+ );
+ // No match - return unchanged
+ return oldData;
+ }
- if (arrayField) {
- const array = [...(oldData as any)[arrayField]];
- const seenIds = new Set();
+ // Wrapped response with array field
+ const arrayField = Object.keys(oldData).find(
+ (key) => Array.isArray((oldData as any)[key]),
+ );
- // Update existing items with deep merge
- for (let i = 0; i < array.length; i++) {
- const item: any = array[i];
- if (resourceMap.has(item.id)) {
- const incomingResource = resourceMap.get(item.id);
- array[i] = deepMerge(item, incomingResource);
- seenIds.add(item.id);
- }
- }
+ if (arrayField) {
+ const array = [...(oldData as any)[arrayField]];
+ const seenIds = new Set();
- // Append new items if:
- // - This is a global list query, OR
- // - The resource passes the filter (belongs in this query scope)
- if (isGlobalList) {
- for (const resource of resources) {
- if (!seenIds.has(resource.id)) {
- array.push(resource);
- }
- }
- } else if (resourceFilter) {
- for (const resource of resources) {
- if (seenIds.has(resource.id)) {
- continue; // Already updated by ID
- }
+ // Update existing items with deep merge
+ for (let i = 0; i < array.length; i++) {
+ const item: any = array[i];
+ if (resourceMap.has(item.id)) {
+ const incomingResource =
+ resourceMap.get(item.id);
+ array[i] = deepMerge(
+ item,
+ incomingResource,
+ );
+ seenIds.add(item.id);
+ }
+ }
- const shouldAppend = resourceFilter(resource);
- if (!shouldAppend) {
- continue;
- }
+ // Append new items if no filter OR resource passes filter
+ for (const resource of resources) {
+ if (seenIds.has(resource.id)) {
+ continue; // Already updated by ID
+ }
- // For Content-based paths, update existing item by content UUID
- if (
- resource.sd_path?.Content &&
- resource.content_identity?.uuid
- ) {
- const contentId = resource.content_identity.uuid;
- const existingIndex = array.findIndex(
- (item: any) =>
- item.content_identity?.uuid === contentId,
- );
+ // Check if we should process this resource
+ if (
+ resourceFilter &&
+ !resourceFilter(resource)
+ ) {
+ continue; // Filtered out
+ }
- if (existingIndex >= 0) {
- // Update existing item
- array[existingIndex] = deepMerge(
- array[existingIndex],
- resource,
- noMergeFields,
- );
- console.log(
- "[Cache] Updated existing file by content UUID:",
- {
- name: resource.name,
- contentId,
- },
- );
- continue;
- }
- }
+ // For Content-based paths, update existing item by content UUID
+ if (
+ resource.sd_path?.Content &&
+ resource.content_identity?.uuid
+ ) {
+ const contentId =
+ resource.content_identity.uuid;
+ const existingIndex = array.findIndex(
+ (item: any) =>
+ item.content_identity?.uuid ===
+ contentId,
+ );
- // New item - append
- array.push(resource);
- }
- }
+ if (existingIndex >= 0) {
+ // Update existing item
+ array[existingIndex] = deepMerge(
+ array[existingIndex],
+ resource,
+ noMergeFields,
+ );
+ console.log(
+ "[Cache] Updated existing file by content UUID:",
+ {
+ name: resource.name,
+ contentId,
+ },
+ );
+ continue;
+ }
+ }
- return { ...oldData, [arrayField]: array };
- }
- }
+ // New item - append
+ array.push(resource);
+ }
- return oldData;
- });
- }
- } else if ("ResourceDeleted" in event) {
- const { resource_type, resource_id } = event.ResourceDeleted;
+ return { ...oldData, [arrayField]: array };
+ }
+ }
- if (resource_type === resourceType) {
- // Atomic update: remove deleted resource
- queryClient.setQueryData(queryKey, (oldData) => {
- if (!oldData) return oldData;
+ return oldData;
+ });
+ }
+ } else if ("ResourceDeleted" in event) {
+ const { resource_type, resource_id } = event.ResourceDeleted;
- if (Array.isArray(oldData)) {
- return oldData.filter(
- (item: any) => item.id !== resource_id,
- ) as O;
- } else if (oldData && typeof oldData === "object") {
- const arrayField = Object.keys(oldData).find((key) =>
- Array.isArray((oldData as any)[key]),
- );
+ if (resource_type === resourceType) {
+ // Atomic update: remove deleted resource
+ queryClient.setQueryData(queryKey, (oldData) => {
+ if (!oldData) return oldData;
- if (arrayField) {
- const array = (oldData as any)[arrayField];
- return {
- ...oldData,
- [arrayField]: array.filter(
- (item: any) => item.id !== resource_id,
- ),
- };
- }
- }
+ if (Array.isArray(oldData)) {
+ return oldData.filter(
+ (item: any) => item.id !== resource_id,
+ ) as O;
+ } else if (oldData && typeof oldData === "object") {
+ const arrayField = Object.keys(oldData).find(
+ (key) => Array.isArray((oldData as any)[key]),
+ );
- return oldData;
- });
- }
- }
- };
+ if (arrayField) {
+ const array = (oldData as any)[arrayField];
+ return {
+ ...oldData,
+ [arrayField]: array.filter(
+ (item: any) => item.id !== resource_id,
+ ),
+ };
+ }
+ }
- // Subscribe to events
- const unsubscribe = client.on("spacedrive-event", handleEvent);
+ return oldData;
+ });
+ }
+ }
+ };
- return () => {
- client.off("spacedrive-event", handleEvent);
- };
- }, [resourceType, queryKey, queryClient, pathScope]);
+ // Subscribe to events
+ const unsubscribe = client.on("spacedrive-event", handleEvent);
- return query;
+ return () => {
+ client.off("spacedrive-event", handleEvent);
+ };
+ }, [resourceType, queryKey, queryClient, pathScope]);
+
+ return query;
}