From ca5107e437edb5748588fb547cefe64977665581 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sun, 21 Dec 2025 08:00:58 -0800 Subject: [PATCH 01/12] Refactor SpaceItem component and enhance drop zone management - Updated the SpaceItem component to improve the handling of custom onClick and context menu behaviors, allowing for more flexible interactions. - Introduced new hooks for managing drop zones, enabling better support for file insertion and move-into operations. - Simplified the logic for determining active states and resolved metadata for space items, enhancing overall performance and maintainability. - Added utility functions for item metadata resolution and drop target identification, streamlining the component's functionality. - Improved styling and structure for insertion indicators and drop highlights, ensuring a more intuitive user experience. --- core/src/ops/indexing/ephemeral/writer.rs | 17 +- core/src/ops/indexing/job.rs | 12 +- .../components/SpacesSidebar/SpaceItem.tsx | 780 ++++++------------ .../components/SpacesSidebar/hooks/index.ts | 29 + .../SpacesSidebar/hooks/spaceItemUtils.ts | 277 +++++++ .../SpacesSidebar/hooks/useSpaceItemActive.ts | 99 +++ .../hooks/useSpaceItemContextMenu.ts | 123 +++ .../hooks/useSpaceItemDropZones.ts | 108 +++ .../interface/src/hooks/useContextMenu.ts | 2 +- 9 files changed, 899 insertions(+), 548 deletions(-) create mode 100644 packages/interface/src/components/SpacesSidebar/hooks/index.ts create mode 100644 packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts create mode 100644 packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemActive.ts create mode 100644 packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts create mode 100644 packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemDropZones.ts diff --git a/core/src/ops/indexing/ephemeral/writer.rs b/core/src/ops/indexing/ephemeral/writer.rs index 5cbb2db85..81e5599b6 100644 --- a/core/src/ops/indexing/ephemeral/writer.rs +++ b/core/src/ops/indexing/ephemeral/writer.rs @@ -359,8 +359,21 @@ impl IndexPersistence for MemoryAdapter { }; if let Some(content_kind) = content_kind { - self.emit_resource_changed(entry_uuid, &entry.path, &metadata, content_kind) - .await; + // Skip event emission for hidden files (dotfiles) to match query filtering behavior. + // Hidden files are still indexed but won't trigger UI updates since they're + // filtered out by default in directory_listing queries. + // TODO: make this configurable + let is_hidden = entry + .path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with('.')) + .unwrap_or(false); + + if !is_hidden { + self.emit_resource_changed(entry_uuid, &entry.path, &metadata, content_kind) + .await; + } } Ok(entry_id) diff --git a/core/src/ops/indexing/job.rs b/core/src/ops/indexing/job.rs index 1717f15fe..8de7fec9e 100644 --- a/core/src/ops/indexing/job.rs +++ b/core/src/ops/indexing/job.rs @@ -136,10 +136,6 @@ impl IndexerJobConfig { } } - /// Creates config for ephemeral browsing (external drives, network shares). - /// - /// Hidden files (dotfiles) are filtered by default to match typical file browser - /// behavior. Use `rule_toggles.no_hidden = false` if hidden files are needed. pub fn ephemeral_browse(path: SdPath, scope: IndexScope) -> Self { Self { location_id: None, @@ -152,13 +148,7 @@ impl IndexerJobConfig { } else { None }, - // Filter hidden files for ephemeral browsing to match file browser expectations - // and prevent event/query mismatch where hidden files emit events but are - // filtered from query results. - rule_toggles: super::rules::RuleToggles { - no_hidden: true, - ..Default::default() - }, + rule_toggles: Default::default(), } } diff --git a/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx b/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx index 104307a41..b10b40f44 100644 --- a/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx +++ b/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx @@ -1,216 +1,217 @@ -import { useNavigate, useLocation } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import clsx from "clsx"; -import { useState, useEffect } from "react"; -import { - House, - Clock, - Heart, - Folder, - HardDrive, - Tag as TagIcon, - FolderOpen, - MagnifyingGlass, - Trash, - Database, - Folders, -} from "@phosphor-icons/react"; -import { Location } from "@sd/assets/icons"; -import type { - SpaceItem as SpaceItemType, - ItemType, - File, -} from "@sd/ts-client"; +import type { SpaceItem as SpaceItemType } from "@sd/ts-client"; import { Thumb } from "../Explorer/File/Thumb"; -import { useContextMenu } from "../../hooks/useContextMenu"; -import { usePlatform } from "../../platform"; -import { useLibraryMutation } from "../../context"; -import { useDroppable, useDndContext } from "@dnd-kit/core"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { useExplorer } from "../Explorer/context"; -interface SpaceItemProps { - item: SpaceItemType; - /** Optional component to render on the right side (e.g., badges, status indicators) */ - rightComponent?: React.ReactNode; - /** Optional className to override default styling */ - className?: string; - /** Optional icon weight (default: "bold") */ - iconWeight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone"; - /** Optional onClick handler to override default navigation */ - onClick?: () => void; - /** Volume data for constructing explorer path */ - volumeData?: { device_slug: string; mount_path: string }; - /** Optional custom icon (as image path) to override default icon */ - customIcon?: string; - /** Optional custom label to override automatic label detection */ - customLabel?: string; - /** Whether this is the last item in the list (for showing bottom insertion line) */ - isLastItem?: boolean; - /** Whether this item supports insertion (reordering) - false for system groups */ - allowInsertion?: boolean; - /** The space ID this item belongs to (for adding items on insertion) */ - spaceId?: string; - /** The group ID this item belongs to (for adding items on insertion) */ - groupId?: string | null; - /** Whether this item is sortable (can be reordered) */ - sortable?: boolean; - /** Optional onContextMenu handler to override default context menu */ +import { + resolveItemMetadata, + isRawLocation, + type IconData, +} from "./hooks/spaceItemUtils"; +import { useSpaceItemActive } from "./hooks/useSpaceItemActive"; +import { useSpaceItemDropZones } from "./hooks/useSpaceItemDropZones"; +import { useSpaceItemContextMenu } from "./hooks/useSpaceItemContextMenu"; + +// Overrides for customizing item appearance and behavior +export interface SpaceItemOverrides { + label?: string; + icon?: string; + onClick?: (e?: React.MouseEvent) => void; onContextMenu?: (e: React.MouseEvent) => void; } -function getItemIcon(itemType: ItemType): any { - if (itemType === "Overview") return { type: "component", icon: House }; - if (itemType === "Recents") return { type: "component", icon: Clock }; - if (itemType === "Favorites") return { type: "component", icon: Heart }; - if (itemType === "FileKinds") return { type: "component", icon: Folders }; - if (typeof itemType === "object" && "Location" in itemType) - return { type: "image", icon: Location }; - if (typeof itemType === "object" && "Volume" in itemType) - return { type: "component", icon: HardDrive }; - if (typeof itemType === "object" && "Tag" in itemType) - return { type: "component", icon: TagIcon }; - if (typeof itemType === "object" && "Path" in itemType) - return { type: "image", icon: Location }; - return { type: "image", icon: Location }; +export interface SpaceItemProps { + item: SpaceItemType; + spaceId?: string; + groupId?: string | null; + // Behavior flags + sortable?: boolean; + allowInsertion?: boolean; + isLastItem?: boolean; + // Overrides + overrides?: SpaceItemOverrides; + rightComponent?: React.ReactNode; + // Legacy props (for backwards compatibility during migration) + volumeData?: { device_slug: string; mount_path: string }; + customIcon?: string; + customLabel?: string; + onClick?: (e?: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; + className?: string; } -function getItemLabel(itemType: ItemType): string { - if (itemType === "Overview") return "Overview"; - if (itemType === "Recents") return "Recents"; - if (itemType === "Favorites") return "Favorites"; - if (itemType === "FileKinds") return "File Kinds"; - if (typeof itemType === "object" && "Location" in itemType) { - return itemType.Location.name || "Unnamed Location"; +// Icon component that handles both component icons and image icons +function ItemIcon({ icon }: { icon: IconData }) { + if (icon.type === "image") { + return ; } - if (typeof itemType === "object" && "Volume" in itemType) { - return itemType.Volume.name || "Unnamed Volume"; - } - if (typeof itemType === "object" && "Tag" in itemType) { - return itemType.Tag.name || "Unnamed Tag"; - } - if (typeof itemType === "object" && "Path" in itemType) { - // Extract name from path - const path = itemType.Path.sd_path; - if (typeof path === "object" && "Physical" in path) { - const parts = path.Physical.path.split("/"); - return parts[parts.length - 1] || "Path"; - } - return "Path"; - } - return "Unknown"; + const IconComponent = icon.icon; + return ( + + + + ); } -function getItemPath( - itemType: ItemType, - volumeData?: { device_slug: string; mount_path: string }, - resolvedFile?: File, - itemSdPath?: any -): string | null { - if (itemType === "Overview") return "/"; - if (itemType === "Recents") return "/recents"; - if (itemType === "Favorites") return "/favorites"; - if (itemType === "FileKinds") return "/file-kinds"; - if (typeof itemType === "object" && "Location" in itemType) { - // Use explorer route with location's SD path (passed from item.sd_path) - if (itemSdPath) { - return `/explorer?path=${encodeURIComponent(JSON.stringify(itemSdPath))}`; - } - return null; +// Insertion line indicator +function InsertionLine({ visible }: { visible: boolean }) { + if (!visible) return null; + return ( +
+ ); +} + +// Bottom insertion line (for last items) +function BottomInsertionLine({ visible }: { visible: boolean }) { + if (!visible) return null; + return ( +
+ ); +} + +// Drop highlight ring for drop-into targets +function DropHighlight({ visible }: { visible: boolean }) { + if (!visible) return null; + return ( +
+ ); +} + +// Drop zone overlays (invisible hit areas) +interface DropZoneOverlaysProps { + isDropTarget: boolean; + setTopRef: (node: HTMLElement | null) => void; + setBottomRef: (node: HTMLElement | null) => void; + setMiddleRef: (node: HTMLElement | null) => void; +} + +function DropZoneOverlays({ + isDropTarget, + setTopRef, + setBottomRef, + setMiddleRef, +}: DropZoneOverlaysProps) { + if (isDropTarget) { + return ( + <> + {/* Top zone - insertion above */} +
+ {/* Middle zone - drop into folder */} +
+ {/* Bottom zone - insertion below */} +
+ + ); } - if (typeof itemType === "object" && "Volume" in itemType) { - // Navigate to explorer with volume's root path - if (volumeData) { - const sdPath = { - Physical: { - device_slug: volumeData.device_slug, - path: volumeData.mount_path || "/", - }, - }; - return `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}`; - } - return null; - } - if (typeof itemType === "object" && "Tag" in itemType) - return `/tag/${itemType.Tag.tag_id}`; - if (typeof itemType === "object" && "Path" in itemType) { - // Navigate to explorer with the SD path - return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`; - } - return null; + + return ( + <> + {/* Top zone - insertion above */} +
+ {/* Bottom zone - insertion below */} +
+ + ); } export function SpaceItem({ item, - rightComponent, - className, - iconWeight = "bold", - onClick, - volumeData, - customIcon, - customLabel, - isLastItem = false, - allowInsertion = true, spaceId, groupId, sortable = false, - onContextMenu, + allowInsertion = true, + isLastItem = false, + overrides, + rightComponent, + // Legacy props + volumeData, + customIcon, + customLabel, + onClick: legacyOnClick, + onContextMenu: legacyOnContextMenu, + className, }: SpaceItemProps) { const navigate = useNavigate(); - const location = useLocation(); - const platform = usePlatform(); - const deleteItem = useLibraryMutation("spaces.delete_item"); - const indexVolume = useLibraryMutation("volumes.index"); - const { active } = useDndContext(); - const { currentView, currentPath } = useExplorer(); - - // Disable insertion drop zones when dragging groups or space items (they have 'label' in their data) - const isDraggingSortableItem = active?.data?.current?.label != null; - // Check if this is a raw location object (has 'name' and 'sd_path' but no 'item_type') - const isRawLocation = - "name" in item && "sd_path" in item && !item.item_type; + // Merge legacy props into overrides + const effectiveOverrides: SpaceItemOverrides = { + ...overrides, + label: overrides?.label ?? customLabel, + icon: overrides?.icon ?? customIcon, + onClick: overrides?.onClick ?? legacyOnClick, + onContextMenu: overrides?.onContextMenu ?? legacyOnContextMenu, + }; - // Check if we have a resolved file - const resolvedFile = item.resolved_file as File | undefined; - - let iconData, label, path; - - if (isRawLocation) { - // Handle raw location object - iconData = { type: "image", icon: Location }; - label = (item as any).name || "Unnamed Location"; - // Use explorer path with the location's sd_path - const sdPath = (item as any).sd_path; - path = sdPath ? `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}` : null; - } else { - // Handle proper SpaceItem - iconData = getItemIcon(item.item_type); - // Use resolved file name if available, otherwise parse from item_type - label = resolvedFile?.name || getItemLabel(item.item_type); - // Pass item.sd_path for locations (available on SpaceItem objects) - path = getItemPath(item.item_type, volumeData, resolvedFile, (item as any).sd_path); - } - - // Override with custom icon if provided - if (customIcon) { - iconData = { type: "image", icon: customIcon }; - } - - // Override with custom label if provided - if (customLabel) { - label = customLabel; - } - - // Sortable hook (for reordering) - must be after label is defined - const sortableProps = useSortable({ - id: item.id, - disabled: !sortable, - data: { - label: label, - }, + // Resolve metadata (icon, label, path) + const { icon, label, path } = resolveItemMetadata(item, { + volumeData, + customIcon: effectiveOverrides.icon, + customLabel: effectiveOverrides.label, }); + // Get resolved file for thumbnail rendering + const resolvedFile = isRawLocation(item) + ? undefined + : (item as SpaceItemType).resolved_file; + + // Active state detection + const isActive = useSpaceItemActive({ + item: item as SpaceItemType, + path, + hasCustomOnClick: !!effectiveOverrides.onClick, + }); + + // Drop zone management + const dropZones = useSpaceItemDropZones({ + item: item as SpaceItemType, + allowInsertion, + spaceId, + groupId, + volumeData, + }); + + // Context menu + const contextMenu = useSpaceItemContextMenu({ + item: item as SpaceItemType, + path, + spaceId, + }); + + // Sortable drag/drop const { attributes: sortableAttributes, listeners: sortableListeners, @@ -218,194 +219,31 @@ export function SpaceItem({ transform, transition, isDragging: isSortableDragging, - } = sortableProps; + } = useSortable({ + id: (item as SpaceItemType).id, + disabled: !sortable, + data: { label }, + }); - const style = sortable ? { - transform: CSS.Transform.toString(transform), - transition, - } : undefined; - - // Check if this item is active - const isActive = (() => { - // For items with custom onClick (like virtual device views), ONLY check virtual view state - // These items represent virtual views and should never use path-based matching - if (onClick) { - if (currentView) { - // Check if this item matches the current virtual view - // Convert both IDs to strings for comparison since URL params are always strings - const itemIdStr = String(item.id); - const isViewMatch = currentView.view === "device" && currentView.id === itemIdStr; - - console.log("[SpaceItem] Virtual view check (with onClick):", { - label: customLabel || label, - currentView, - itemId: item.id, - itemIdStr, - isViewMatch, - }); - - return isViewMatch; + const style = sortable + ? { + transform: CSS.Transform.toString(transform), + transition, } + : undefined; - // No current view active - virtual items are never active on regular routes - console.log("[SpaceItem] Virtual item on regular route:", { - label: customLabel || label, - hasOnClick: true, - currentView: null, - }); - return false; - } - - // Check virtual view state for items without custom onClick - if (currentView) { - const itemIdStr = String(item.id); - const isViewMatch = currentView.view === "device" && currentView.id === itemIdStr; - - console.log("[SpaceItem] Virtual view check (no onClick):", { - label: customLabel || label, - currentView, - itemId: item.id, - isViewMatch, - }); - - if (isViewMatch) { - return true; - } - } - - // Check path-based navigation - if (currentPath && path && path.startsWith("/explorer?")) { - const itemPathParam = new URLSearchParams(path.split("?")[1]).get("path"); - if (itemPathParam) { - try { - const itemSdPath = JSON.parse(decodeURIComponent(itemPathParam)); - return JSON.stringify(currentPath) === JSON.stringify(itemSdPath); - } catch { - // Fall through to URL-based comparison - } - } - } - - if (!path) return false; - - // Special routes: exact pathname match - if (!path.startsWith("/explorer?")) { - return location.pathname === path; - } - - // Fallback: Explorer routes via URL comparison - if (location.pathname === "/explorer") { - const currentSearchParams = new URLSearchParams(location.search); - const currentPathParam = currentSearchParams.get("path"); - const itemPathParam = new URLSearchParams(path.split("?")[1]).get("path"); - - if (currentPathParam && itemPathParam) { - try { - const currentSdPath = JSON.parse(decodeURIComponent(currentPathParam)); - const itemSdPath = JSON.parse(decodeURIComponent(itemPathParam)); - return JSON.stringify(currentSdPath) === JSON.stringify(itemSdPath); - } catch { - return currentPathParam === itemPathParam; - } - } - } - - return false; - })(); - - const handleClick = () => { - if (onClick) { - onClick(); + // Event handlers + const handleClick = (e: React.MouseEvent) => { + if (effectiveOverrides.onClick) { + effectiveOverrides.onClick(e); } else if (path) { navigate(path); } }; - // Context menu for space items - const contextMenu = useContextMenu({ - items: [ - { - icon: FolderOpen, - label: "Open", - onClick: () => { - if (path) navigate(path); - }, - condition: () => !!path, - }, - { - icon: Database, - label: "Index Volume", - onClick: async () => { - if (typeof item.item_type === "object" && "Volume" in item.item_type) { - const volumeItem = item.item_type.Volume; - // Extract volume fingerprint from the item - // We'll need to get this from the volume data - const fingerprint = (item as any).fingerprint || volumeItem.volume_id; - - try { - const result = await indexVolume.mutateAsync({ - fingerprint: fingerprint.toString(), - scope: "Recursive", - }); - console.log("Volume indexed:", result.message); - } catch (err) { - console.error("Failed to index volume:", err); - } - } - }, - condition: () => typeof item.item_type === "object" && "Volume" in item.item_type, - }, - { type: "separator" }, - { - icon: MagnifyingGlass, - label: "Show in Finder", - onClick: async () => { - // For Path items, get the physical path - if (typeof item.item_type === "object" && "Path" in item.item_type) { - const sdPath = item.item_type.Path.sd_path; - if (typeof sdPath === "object" && "Physical" in sdPath) { - const physicalPath = sdPath.Physical.path; - if (platform.revealFile) { - try { - await platform.revealFile(physicalPath); - } catch (err) { - console.error("Failed to reveal file:", err); - } - } - } - } - }, - keybind: "⌘⇧R", - condition: () => { - if (typeof item.item_type === "object" && "Path" in item.item_type) { - const sdPath = item.item_type.Path.sd_path; - return typeof sdPath === "object" && "Physical" in sdPath && !!platform.revealFile; - } - return false; - }, - }, - { type: "separator" }, - { - icon: Trash, - label: "Remove from Space", - onClick: async () => { - try { - await deleteItem.mutateAsync({ item_id: item.id }); - } catch (err) { - console.error("Failed to remove item:", err); - } - }, - variant: "danger" as const, - // All space items can be removed (Overview, Recents, Favorites, FileKinds, Locations, Volumes, Tags, Paths) - condition: () => spaceId != null, - }, - ], - }); - const handleContextMenu = async (e: React.MouseEvent) => { - // Use custom handler if provided, otherwise use default - if (onContextMenu) { - onContextMenu(e); + if (effectiveOverrides.onContextMenu) { + effectiveOverrides.onContextMenu(e); return; } @@ -414,196 +252,70 @@ export function SpaceItem({ await contextMenu.show(e); }; - /** - * Drop Target Detection - * - * SpaceItems can be drop targets in two ways: - * - * 1. Insertion Points (all items): - * - Show blue line above/below - * - Allows reordering sidebar items - * - Top/bottom zones (25% or 50% of height) - * - * 2. Move-Into Targets (locations/volumes/folders only): - * - Show blue ring around entire item - * - Allows moving files into that location - * - Middle zone (50% of height, only for drop targets) - * - * Target Types: - * - "location": Indexed location (raw or ItemType::Location) - * - "volume": Storage volume (ItemType::Volume) - * - "folder": Directory path (ItemType::Path with kind=Directory) - */ - const isDropTarget = - isRawLocation || - (typeof item.item_type === "object" && - ("Location" in item.item_type || - "Volume" in item.item_type || - ("Path" in item.item_type && resolvedFile?.kind === "Directory"))); - - let targetType: "location" | "volume" | "folder" | "other" = "other"; - if (isRawLocation) { - targetType = "location"; - } else if (typeof item.item_type === "object") { - if ("Location" in item.item_type) targetType = "location"; - else if ("Volume" in item.item_type) targetType = "volume"; - else if ("Path" in item.item_type && resolvedFile?.kind === "Directory") targetType = "folder"; - } - - // Debug logging for folder drop targets - useEffect(() => { - if (typeof item.item_type === "object" && "Path" in item.item_type) { - console.log("[SpaceItem] Folder item:", { - label, - isDropTarget, - targetType, - hasResolvedFile: !!resolvedFile, - resolvedFileKind: resolvedFile?.kind, - sdPath: item.item_type.Path.sd_path, - }); - } - }, [item, isDropTarget, targetType, resolvedFile, label]); - - const { setNodeRef: setTopRef, isOver: isOverTop } = useDroppable({ - id: `space-item-${item.id}-top`, - disabled: !allowInsertion || isDraggingSortableItem, - data: { - action: "insert-before", - itemId: item.id, - spaceId, - groupId, - }, - }); - - const { setNodeRef: setBottomRef, isOver: isOverBottom } = useDroppable({ - id: `space-item-${item.id}-bottom`, - disabled: !allowInsertion || isDraggingSortableItem, - data: { - action: "insert-after", - itemId: item.id, - spaceId, - groupId, - }, - }); - - // Build the target path for drop operations - const targetPath = isRawLocation - ? (item as any).sd_path - : targetType === "folder" && typeof item.item_type === "object" && "Path" in item.item_type - ? item.item_type.Path.sd_path - : targetType === "volume" && typeof item.item_type === "object" && "Volume" in item.item_type && volumeData - ? { Physical: { device_slug: volumeData.device_slug, path: volumeData.mount_path || "/" } } - : targetType === "location" && typeof item.item_type === "object" && "Location" in item.item_type && (item as any).sd_path - ? (item as any).sd_path - : undefined; - - // Debug log the drop data - useEffect(() => { - if (isDropTarget && targetType === "folder") { - console.log("[SpaceItem] Drop zone data for folder:", { - label, - targetType, - targetPath, - itemId: item.id, - }); - } - }, [isDropTarget, targetType, targetPath, label, item.id]); - - const { setNodeRef: setMiddleRef, isOver: isOverMiddle } = useDroppable({ - id: `space-item-${item.id}-middle`, - disabled: !isDropTarget || isDraggingSortableItem, - data: { - action: "move-into", - targetType, - targetId: item.id, - targetPath, - }, - }); + // Computed visibility for indicators + const showTopLine = + dropZones.isOverTop && + !isSortableDragging && + !dropZones.isDraggingSortableItem; + const showBottomLine = + dropZones.isOverBottom && + isLastItem && + !dropZones.isDraggingSortableItem; + const showDropHighlight = + dropZones.isOverMiddle && + dropZones.isDropTarget && + !isSortableDragging && + !dropZones.isDraggingSortableItem; return (
- {/* Insertion line indicator - only show top (bottom of previous item handles gaps) */} - {isOverTop && !isSortableDragging && !isDraggingSortableItem && ( -
- )} - - {/* Ring highlight for drop-into */} - {isOverMiddle && isDropTarget && !isSortableDragging && !isDraggingSortableItem && ( -
- )} + +
- {/* Drop zones - invisible overlays, only active during drag */} - {isDropTarget ? ( - <> - {/* Top zone - insertion above */} -
- {/* Middle zone - drop into folder */} -
- {/* Bottom zone - insertion below */} -
- - ) : ( - <> - {/* Top zone - insertion above */} -
- {/* Bottom zone - insertion below */} -
- - )} +
- {/* Insertion line indicator - bottom (only for last item to allow dropping at end) */} - {isOverBottom && isLastItem && !isDraggingSortableItem && ( -
- )} +
); } diff --git a/packages/interface/src/components/SpacesSidebar/hooks/index.ts b/packages/interface/src/components/SpacesSidebar/hooks/index.ts new file mode 100644 index 000000000..d53d3b149 --- /dev/null +++ b/packages/interface/src/components/SpacesSidebar/hooks/index.ts @@ -0,0 +1,29 @@ +// Space item utilities +export { + isOverviewItem, + isRecentsItem, + isFavoritesItem, + isFileKindsItem, + isLocationItem, + isVolumeItem, + isTagItem, + isPathItem, + isRawLocation, + isDropTargetItem, + getDropTargetType, + buildDropTargetPath, + resolveItemMetadata, + type IconData, + type ItemMetadata, + type ResolveMetadataOptions, + type DropTargetType, +} from "./spaceItemUtils"; + +// Space item hooks +export { useSpaceItemActive } from "./useSpaceItemActive"; +export { useSpaceItemDropZones, type UseSpaceItemDropZonesResult } from "./useSpaceItemDropZones"; +export { useSpaceItemContextMenu } from "./useSpaceItemContextMenu"; + +// Space data hooks +export { useSpaces, useSpaceLayout } from "./useSpaces"; + diff --git a/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts b/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts new file mode 100644 index 000000000..f171bf938 --- /dev/null +++ b/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts @@ -0,0 +1,277 @@ +import { + House, + Clock, + Heart, + Folder, + HardDrive, + Tag as TagIcon, + Folders, +} from "@phosphor-icons/react"; +import { Location } from "@sd/assets/icons"; +import type { + SpaceItem as SpaceItemType, + ItemType, + File, + SdPath, +} from "@sd/ts-client"; +import type { Icon } from "@phosphor-icons/react"; + +// Icon data returned from metadata resolution +export type IconData = + | { type: "component"; icon: Icon } + | { type: "image"; icon: string }; + +// Metadata resolved for a space item +export interface ItemMetadata { + icon: IconData; + label: string; + path: string | null; +} + +// Type guards for ItemType discrimination +export function isOverviewItem(t: ItemType): t is "Overview" { + return t === "Overview"; +} + +export function isRecentsItem(t: ItemType): t is "Recents" { + return t === "Recents"; +} + +export function isFavoritesItem(t: ItemType): t is "Favorites" { + return t === "Favorites"; +} + +export function isFileKindsItem(t: ItemType): t is "FileKinds" { + return t === "FileKinds"; +} + +export function isLocationItem( + t: ItemType, +): t is { Location: { location_id: string } } { + return typeof t === "object" && "Location" in t; +} + +export function isVolumeItem( + t: ItemType, +): t is { Volume: { volume_id: string } } { + return typeof t === "object" && "Volume" in t; +} + +export function isTagItem(t: ItemType): t is { Tag: { tag_id: string } } { + return typeof t === "object" && "Tag" in t; +} + +export function isPathItem(t: ItemType): t is { Path: { sd_path: SdPath } } { + return typeof t === "object" && "Path" in t; +} + +// Check if item is a "raw" location (legacy format with name/sd_path but no item_type) +export function isRawLocation( + item: SpaceItemType | Record, +): boolean { + return "name" in item && "sd_path" in item && !("item_type" in item); +} + +// Get icon data for an item type +function getItemIcon(itemType: ItemType): IconData { + if (isOverviewItem(itemType)) return { type: "component", icon: House }; + if (isRecentsItem(itemType)) return { type: "component", icon: Clock }; + if (isFavoritesItem(itemType)) return { type: "component", icon: Heart }; + if (isFileKindsItem(itemType)) return { type: "component", icon: Folders }; + if (isLocationItem(itemType)) return { type: "image", icon: Location }; + if (isVolumeItem(itemType)) return { type: "component", icon: HardDrive }; + if (isTagItem(itemType)) return { type: "component", icon: TagIcon }; + if (isPathItem(itemType)) return { type: "image", icon: Location }; + return { type: "image", icon: Location }; +} + +// Get label for an item type +function getItemLabel(itemType: ItemType, resolvedFile?: File | null): string { + if (isOverviewItem(itemType)) return "Overview"; + if (isRecentsItem(itemType)) return "Recents"; + if (isFavoritesItem(itemType)) return "Favorites"; + if (isFileKindsItem(itemType)) return "File Kinds"; + if (isLocationItem(itemType)) return "Location"; + if (isVolumeItem(itemType)) return "Volume"; + if (isTagItem(itemType)) return "Tag"; + if (isPathItem(itemType)) { + // Use resolved file name if available, otherwise extract from path + if (resolvedFile?.name) return resolvedFile.name; + const sdPath = itemType.Path.sd_path; + if (typeof sdPath === "object" && "Physical" in sdPath) { + const parts = ( + sdPath as { Physical: { path: string } } + ).Physical.path.split("/"); + return parts[parts.length - 1] || "Path"; + } + return "Path"; + } + return "Unknown"; +} + +// Build navigation path for an item +function getItemPath( + itemType: ItemType, + volumeData?: { device_slug: string; mount_path: string }, + itemSdPath?: SdPath, +): string | null { + if (isOverviewItem(itemType)) return "/"; + if (isRecentsItem(itemType)) return "/recents"; + if (isFavoritesItem(itemType)) return "/favorites"; + if (isFileKindsItem(itemType)) return "/file-kinds"; + + if (isLocationItem(itemType)) { + // Use explorer route with location's SD path (passed from item.sd_path) + if (itemSdPath) { + return `/explorer?path=${encodeURIComponent(JSON.stringify(itemSdPath))}`; + } + return null; + } + + if (isVolumeItem(itemType)) { + // Navigate to explorer with volume's root path + if (volumeData) { + const sdPath = { + Physical: { + device_slug: volumeData.device_slug, + path: volumeData.mount_path || "/", + }, + }; + return `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}`; + } + return null; + } + + if (isTagItem(itemType)) { + return `/tag/${itemType.Tag.tag_id}`; + } + + if (isPathItem(itemType)) { + // Navigate to explorer with the SD path + return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`; + } + + return null; +} + +// Options for resolving item metadata +export interface ResolveMetadataOptions { + volumeData?: { device_slug: string; mount_path: string }; + customIcon?: string; + customLabel?: string; +} + +// Resolve all metadata for a space item in one call +export function resolveItemMetadata( + item: SpaceItemType | Record, + options: ResolveMetadataOptions = {}, +): ItemMetadata { + const { volumeData, customIcon, customLabel } = options; + + // Handle raw location object (legacy format) + if (isRawLocation(item)) { + const rawItem = item as { name?: string; sd_path?: SdPath }; + const label = customLabel || rawItem.name || "Unnamed Location"; + const path = rawItem.sd_path + ? `/explorer?path=${encodeURIComponent(JSON.stringify(rawItem.sd_path))}` + : null; + + return { + icon: customIcon + ? { type: "image", icon: customIcon } + : { type: "image", icon: Location }, + label, + path, + }; + } + + // Handle proper SpaceItem + const spaceItem = item as SpaceItemType; + const resolvedFile = spaceItem.resolved_file; + const itemSdPath = (spaceItem as SpaceItemType & { sd_path?: SdPath }) + .sd_path; + + const icon: IconData = customIcon + ? { type: "image", icon: customIcon } + : getItemIcon(spaceItem.item_type); + + const label = + customLabel || + resolvedFile?.name || + getItemLabel(spaceItem.item_type, resolvedFile); + + const path = getItemPath(spaceItem.item_type, volumeData, itemSdPath); + + return { icon, label, path }; +} + +// Determine if an item can be a drop target (for files to be moved into) +export function isDropTargetItem( + item: SpaceItemType | Record, +): boolean { + if (isRawLocation(item)) return true; + + const spaceItem = item as SpaceItemType; + const itemType = spaceItem.item_type; + const resolvedFile = spaceItem.resolved_file; + + return ( + isLocationItem(itemType) || + isVolumeItem(itemType) || + (isPathItem(itemType) && resolvedFile?.kind === "Directory") + ); +} + +// Get the target type for drop operations +export type DropTargetType = "location" | "volume" | "folder" | "other"; + +export function getDropTargetType( + item: SpaceItemType | Record, +): DropTargetType { + if (isRawLocation(item)) return "location"; + + const spaceItem = item as SpaceItemType; + const itemType = spaceItem.item_type; + const resolvedFile = spaceItem.resolved_file; + + if (isLocationItem(itemType)) return "location"; + if (isVolumeItem(itemType)) return "volume"; + if (isPathItem(itemType) && resolvedFile?.kind === "Directory") + return "folder"; + + return "other"; +} + +// Build target path for drop operations +export function buildDropTargetPath( + item: SpaceItemType | Record, + volumeData?: { device_slug: string; mount_path: string }, +): SdPath | undefined { + if (isRawLocation(item)) { + return (item as { sd_path?: SdPath }).sd_path; + } + + const spaceItem = item as SpaceItemType; + const itemType = spaceItem.item_type; + const itemSdPath = (spaceItem as SpaceItemType & { sd_path?: SdPath }) + .sd_path; + + if (isPathItem(itemType)) { + return itemType.Path.sd_path; + } + + if (isVolumeItem(itemType) && volumeData) { + return { + Physical: { + device_slug: volumeData.device_slug, + path: volumeData.mount_path || "/", + }, + }; + } + + if (isLocationItem(itemType) && itemSdPath) { + return itemSdPath; + } + + return undefined; +} diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemActive.ts b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemActive.ts new file mode 100644 index 000000000..e62f13bc6 --- /dev/null +++ b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemActive.ts @@ -0,0 +1,99 @@ +import { useLocation } from "react-router-dom"; +import type { SpaceItem as SpaceItemType } from "@sd/ts-client"; +import { useExplorer } from "../../Explorer/context"; + +interface UseSpaceItemActiveOptions { + item: SpaceItemType; + path: string | null; + hasCustomOnClick: boolean; +} + +/** + * Determines if a space item is currently "active" (selected/highlighted). + * + * Active state is determined by matching the current route/view to the item: + * - Virtual views (devices) match by view type and ID + * - Explorer routes match by comparing SD paths + * - Special routes (/, /recents, etc.) match by exact pathname + */ +export function useSpaceItemActive({ + item, + path, + hasCustomOnClick, +}: UseSpaceItemActiveOptions): boolean { + const location = useLocation(); + const { currentView, currentPath } = useExplorer(); + + // Items with custom onClick represent virtual views (like device views). + // They should ONLY match via virtual view state, never path-based matching. + if (hasCustomOnClick) { + if (!currentView) return false; + + const itemIdStr = String(item.id); + return currentView.view === "device" && currentView.id === itemIdStr; + } + + // Check virtual view state for items without custom onClick + if (currentView) { + const itemIdStr = String(item.id); + const isViewMatch = + currentView.view === "device" && currentView.id === itemIdStr; + + if (isViewMatch) return true; + } + + // Check path-based navigation via explorer context + if (currentPath && path && path.startsWith("/explorer?")) { + const itemPathParam = new URLSearchParams(path.split("?")[1]).get( + "path", + ); + if (itemPathParam) { + try { + const itemSdPath = JSON.parse( + decodeURIComponent(itemPathParam), + ); + if ( + JSON.stringify(currentPath) === JSON.stringify(itemSdPath) + ) { + return true; + } + } catch { + // Fall through to URL-based comparison + } + } + } + + if (!path) return false; + + // Special routes (/, /recents, /favorites, etc.): exact pathname match + if (!path.startsWith("/explorer?")) { + return location.pathname === path; + } + + // Explorer routes: compare SD paths via URL + if (location.pathname === "/explorer") { + const currentSearchParams = new URLSearchParams(location.search); + const currentPathParam = currentSearchParams.get("path"); + const itemPathParam = new URLSearchParams(path.split("?")[1]).get( + "path", + ); + + if (currentPathParam && itemPathParam) { + try { + const currentSdPath = JSON.parse( + decodeURIComponent(currentPathParam), + ); + const itemSdPath = JSON.parse( + decodeURIComponent(itemPathParam), + ); + return ( + JSON.stringify(currentSdPath) === JSON.stringify(itemSdPath) + ); + } catch { + return currentPathParam === itemPathParam; + } + } + } + + return false; +} diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts new file mode 100644 index 000000000..615c228cc --- /dev/null +++ b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts @@ -0,0 +1,123 @@ +import { useNavigate } from "react-router-dom"; +import { + FolderOpen, + MagnifyingGlass, + Trash, + Database, +} from "@phosphor-icons/react"; +import type { SpaceItem as SpaceItemType } from "@sd/ts-client"; +import { + useContextMenu, + type ContextMenuItem, + type ContextMenuResult, +} from "../../../hooks/useContextMenu"; +import { usePlatform } from "../../../platform"; +import { useLibraryMutation } from "../../../context"; +import { isVolumeItem, isPathItem } from "./spaceItemUtils"; + +interface UseSpaceItemContextMenuOptions { + item: SpaceItemType; + path: string | null; + spaceId?: string; +} + +/** + * Provides context menu functionality for space items. + * + * Menu items include: + * - Open: Navigate to the item's path + * - Index Volume: Trigger indexing for volume items + * - Show in Finder: Reveal file in OS file manager (Path items only) + * - Remove from Space: Delete the item from the current space + */ +export function useSpaceItemContextMenu({ + item, + path, + spaceId, +}: UseSpaceItemContextMenuOptions): ContextMenuResult { + const navigate = useNavigate(); + const platform = usePlatform(); + const deleteItem = useLibraryMutation("spaces.delete_item"); + const indexVolume = useLibraryMutation("volumes.index"); + + const items: ContextMenuItem[] = [ + { + icon: FolderOpen, + label: "Open", + onClick: () => { + if (path) navigate(path); + }, + condition: () => !!path, + }, + { + icon: Database, + label: "Index Volume", + onClick: async () => { + if (isVolumeItem(item.item_type)) { + const fingerprint = + (item as SpaceItemType & { fingerprint?: string }) + .fingerprint || item.item_type.Volume.volume_id; + + try { + const result = await indexVolume.mutateAsync({ + fingerprint: fingerprint.toString(), + scope: "Recursive", + }); + console.log("Volume indexed:", result.message); + } catch (err) { + console.error("Failed to index volume:", err); + } + } + }, + condition: () => isVolumeItem(item.item_type), + }, + { type: "separator" }, + { + icon: MagnifyingGlass, + label: "Show in Finder", + onClick: async () => { + if (isPathItem(item.item_type)) { + const sdPath = item.item_type.Path.sd_path; + if (typeof sdPath === "object" && "Physical" in sdPath) { + const physicalPath = ( + sdPath as { Physical: { path: string } } + ).Physical.path; + if (platform.revealFile) { + try { + await platform.revealFile(physicalPath); + } catch (err) { + console.error("Failed to reveal file:", err); + } + } + } + } + }, + keybind: "⌘⇧R", + condition: () => { + if (!isPathItem(item.item_type)) return false; + const sdPath = item.item_type.Path.sd_path; + return ( + typeof sdPath === "object" && + "Physical" in sdPath && + !!platform.revealFile + ); + }, + }, + { type: "separator" }, + { + icon: Trash, + label: "Remove from Space", + onClick: async () => { + try { + await deleteItem.mutateAsync({ item_id: item.id }); + } catch (err) { + console.error("Failed to remove item:", err); + } + }, + variant: "danger" as const, + condition: () => spaceId != null, + }, + ]; + + return useContextMenu({ items }); +} diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemDropZones.ts b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemDropZones.ts new file mode 100644 index 000000000..e37844d64 --- /dev/null +++ b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemDropZones.ts @@ -0,0 +1,108 @@ +import { useDroppable, useDndContext } from "@dnd-kit/core"; +import type { SpaceItem as SpaceItemType, SdPath } from "@sd/ts-client"; +import { + isDropTargetItem, + getDropTargetType, + buildDropTargetPath, + type DropTargetType, +} from "./spaceItemUtils"; + +interface UseSpaceItemDropZonesOptions { + item: SpaceItemType; + allowInsertion: boolean; + spaceId?: string; + groupId?: string | null; + volumeData?: { device_slug: string; mount_path: string }; +} + +interface DropZoneRefs { + setTopRef: (node: HTMLElement | null) => void; + setBottomRef: (node: HTMLElement | null) => void; + setMiddleRef: (node: HTMLElement | null) => void; +} + +interface DropZoneState { + isOverTop: boolean; + isOverBottom: boolean; + isOverMiddle: boolean; + isDropTarget: boolean; + targetType: DropTargetType; + targetPath: SdPath | undefined; + isDraggingSortableItem: boolean; +} + +export type UseSpaceItemDropZonesResult = DropZoneRefs & DropZoneState; + +/** + * Manages drop zones for a space item. + * + * SpaceItems support two types of drop interactions: + * 1. Insertion (reordering): Blue line above/below for sidebar item reordering + * 2. Move-into (file operations): Blue ring for moving files into location/folder + */ +export function useSpaceItemDropZones({ + item, + allowInsertion, + spaceId, + groupId, + volumeData, +}: UseSpaceItemDropZonesOptions): UseSpaceItemDropZonesResult { + const { active } = useDndContext(); + + // Disable insertion zones when dragging groups or space items (they have 'label' in data) + const isDraggingSortableItem = active?.data?.current?.label != null; + + // Determine if this item can receive file drops + const isDropTarget = isDropTargetItem(item); + const targetType = getDropTargetType(item); + const targetPath = buildDropTargetPath(item, volumeData); + + // Top zone: insertion above + const { setNodeRef: setTopRef, isOver: isOverTop } = useDroppable({ + id: `space-item-${item.id}-top`, + disabled: !allowInsertion || isDraggingSortableItem, + data: { + action: "insert-before", + itemId: item.id, + spaceId, + groupId, + }, + }); + + // Bottom zone: insertion below + const { setNodeRef: setBottomRef, isOver: isOverBottom } = useDroppable({ + id: `space-item-${item.id}-bottom`, + disabled: !allowInsertion || isDraggingSortableItem, + data: { + action: "insert-after", + itemId: item.id, + spaceId, + groupId, + }, + }); + + // Middle zone: drop into folder/location + const { setNodeRef: setMiddleRef, isOver: isOverMiddle } = useDroppable({ + id: `space-item-${item.id}-middle`, + disabled: !isDropTarget || isDraggingSortableItem, + data: { + action: "move-into", + targetType, + targetId: item.id, + targetPath, + }, + }); + + return { + setTopRef, + setBottomRef, + setMiddleRef, + isOverTop, + isOverBottom, + isOverMiddle, + isDropTarget, + targetType, + targetPath, + isDraggingSortableItem, + }; +} diff --git a/packages/interface/src/hooks/useContextMenu.ts b/packages/interface/src/hooks/useContextMenu.ts index b316e1ddc..4dcea1eab 100644 --- a/packages/interface/src/hooks/useContextMenu.ts +++ b/packages/interface/src/hooks/useContextMenu.ts @@ -18,7 +18,7 @@ export interface ContextMenuConfig { items: ContextMenuItem[]; } -interface ContextMenuResult { +export interface ContextMenuResult { show: (e: React.MouseEvent) => Promise; menuData: ContextMenuItem[] | null; closeMenu: () => void; From 14c6c66dc6f7284af528dad0b832111bf0af83c3 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sun, 21 Dec 2025 08:08:44 -0800 Subject: [PATCH 02/12] Enhance space item active state logic - Updated the `useSpaceItemActive` hook to ensure that regular items are not marked as active when a virtual view is active, improving the accuracy of active state determination. - Refined path-based navigation checks to only apply when on the explorer route, enhancing the reliability of navigation logic. --- .../SpacesSidebar/hooks/useSpaceItemActive.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemActive.ts b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemActive.ts index e62f13bc6..111959b4f 100644 --- a/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemActive.ts +++ b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemActive.ts @@ -40,10 +40,20 @@ export function useSpaceItemActive({ currentView.view === "device" && currentView.id === itemIdStr; if (isViewMatch) return true; + + // When a virtual view is active, regular items should NOT be active + // even if their path happens to match. Virtual views own the display. + return false; } // Check path-based navigation via explorer context - if (currentPath && path && path.startsWith("/explorer?")) { + // Only use currentPath matching when we're actually on the explorer route + if ( + location.pathname === "/explorer" && + currentPath && + path && + path.startsWith("/explorer?") + ) { const itemPathParam = new URLSearchParams(path.split("?")[1]).get( "path", ); From 7d56d67a7cbb3e20571efe52ff206f085ae5ef7a Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sun, 21 Dec 2025 08:21:55 -0800 Subject: [PATCH 03/12] Refactor DevicePanel to enhance location selection UI - Introduced a new `LocationsScroller` component to improve the display and selection of device locations, allowing for horizontal scrolling. - Updated the `DeviceCard` to utilize the `LocationsScroller`, enhancing user interaction with location buttons. - Added scroll state management to enable smooth scrolling behavior and visual feedback for available scroll directions. - Integrated new icons for navigation buttons, improving the overall aesthetic and usability of the location selection interface. --- .../src/routes/overview/DevicePanel.tsx | 178 +++++++++++++----- 1 file changed, 128 insertions(+), 50 deletions(-) diff --git a/packages/interface/src/routes/overview/DevicePanel.tsx b/packages/interface/src/routes/overview/DevicePanel.tsx index 38705ceec..a16dfdbc5 100644 --- a/packages/interface/src/routes/overview/DevicePanel.tsx +++ b/packages/interface/src/routes/overview/DevicePanel.tsx @@ -1,6 +1,6 @@ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { motion } from "framer-motion"; -import { HardDrive, Plus, Database } from "@phosphor-icons/react"; +import { HardDrive, Plus, Database, CaretLeft, CaretRight } from "@phosphor-icons/react"; import Masonry from "react-masonry-css"; import DriveIcon from "@sd/assets/icons/Drive.png"; import HDDIcon from "@sd/assets/icons/HDD.png"; @@ -10,6 +10,7 @@ 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 LocationIcon from "@sd/assets/icons/Location.png"; +import { TopBarButton } from "@sd/ui/TopBarButton"; import { useNormalizedQuery, useLibraryMutation, @@ -386,54 +387,11 @@ function DeviceCard({ {/* Locations for this device */} {locations.length > 0 && ( -
-
- {locations.map((location) => { - const isSelected = - selectedLocationId === location.id; - return ( - - ); - })} -
-
+ )} {/* Volumes for this device */} @@ -460,6 +418,126 @@ function DeviceCard({ ); } +interface LocationsScrollerProps { + locations: Location[]; + selectedLocationId: string | null; + onLocationSelect?: (location: Location | null) => void; +} + +function LocationsScroller({ + locations, + selectedLocationId, + onLocationSelect, +}: LocationsScrollerProps) { + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateScrollState = () => { + if (!scrollRef.current) return; + const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1); + }; + + useEffect(() => { + updateScrollState(); + window.addEventListener("resize", updateScrollState); + return () => window.removeEventListener("resize", updateScrollState); + }, [locations]); + + const scroll = (direction: "left" | "right") => { + if (!scrollRef.current) return; + const scrollAmount = 200; + scrollRef.current.scrollBy({ + left: direction === "left" ? -scrollAmount : scrollAmount, + behavior: "smooth", + }); + }; + + return ( +
+
+ {/* Left fade and button */} + {canScrollLeft && ( + <> +
+
+ scroll("left")} + /> +
+ + )} + + {/* Scrollable container */} +
+ {locations.map((location) => { + const isSelected = selectedLocationId === location.id; + return ( + + ); + })} +
+ + {/* Right fade and button */} + {canScrollRight && ( + <> +
+
+ scroll("right")} + /> +
+ + )} +
+
+ ); +} + interface VolumeBarProps { volume: VolumeItem; index: number; From ede5b255e3ae309386c532f9c94eda899ef4c444 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sun, 21 Dec 2025 08:50:33 -0800 Subject: [PATCH 04/12] Enhance job management UI and data structures - Introduced a new `JobsScreen` component to manage and display job statuses, allowing users to view running, paused, queued, completed, and failed jobs. - Updated job data structures to include `created_at` and `started_at` timestamps, improving job tracking and reporting. - Refactored job-related components to utilize the new data structure, ensuring consistency across the application. - Enhanced the `JobRow` component to display job duration and status more effectively, improving user experience. - Added filtering options for job visibility, allowing users to toggle between viewing all jobs or only active ones. --- apps/tauri/src/App.tsx | 17 + core/src/infra/job/manager.rs | 15 +- core/src/infra/job/types.rs | 3 +- core/src/ops/jobs/info/output.rs | 6 +- core/src/ops/jobs/info/query.rs | 1 + core/src/ops/jobs/list/output.rs | 4 + core/src/ops/jobs/list/query.rs | 3 + .../src/components/Explorer/context.tsx | 18 + .../JobManager/JobsScreen/JobRow.tsx | 261 +++++++------- .../JobManager/JobsScreen/index.tsx | 310 ++++++++++------- packages/interface/src/index.tsx | 1 + .../src/routes/overview/DevicePanel.tsx | 12 +- packages/ts-client/src/generated/types.ts | 322 ++++++++++-------- 13 files changed, 573 insertions(+), 400 deletions(-) diff --git a/apps/tauri/src/App.tsx b/apps/tauri/src/App.tsx index 8634d6a83..5bb0eebcf 100644 --- a/apps/tauri/src/App.tsx +++ b/apps/tauri/src/App.tsx @@ -7,6 +7,7 @@ import { LocationCacheDemo, PopoutInspector, QuickPreview, + JobsScreen, Settings, PlatformProvider, SpacedriveProvider, @@ -81,6 +82,8 @@ function App() { setRoute("/quick-preview"); } else if (label.startsWith("cache-demo")) { setRoute("/cache-demo"); + } else if (label.startsWith("job-manager")) { + setRoute("/job-manager"); } // Tell Tauri window is ready to be shown @@ -264,6 +267,20 @@ function App() { ); } + if (route === "/job-manager") { + return ( + + + +
+ +
+
+
+
+ ); + } + return ( diff --git a/core/src/infra/job/manager.rs b/core/src/infra/job/manager.rs index 8a7960081..6dd6a153c 100644 --- a/core/src/infra/job/manager.rs +++ b/core/src/infra/job/manager.rs @@ -905,7 +905,8 @@ impl JobManager { device_id, status, progress: progress_percentage, - started_at: chrono::Utc::now(), // TODO: Get actual start time + created_at: chrono::Utc::now(), // Running jobs use current time as fallback + started_at: Some(chrono::Utc::now()), // Running jobs have started completed_at: None, error_message: None, parent_job_id: None, @@ -993,7 +994,8 @@ impl JobManager { device_id, status: current_status, progress: progress_percentage, - started_at: chrono::Utc::now(), // TODO: Get from DB + created_at: chrono::Utc::now(), // Running jobs use current time as fallback + started_at: Some(chrono::Utc::now()), // Running jobs have started completed_at: None, error_message: None, parent_job_id: None, @@ -1069,7 +1071,8 @@ impl JobManager { device_id, status, progress, - started_at: j.started_at.unwrap_or(j.created_at), + created_at: j.created_at, + started_at: j.started_at, completed_at: j.completed_at, error_message: j.error_message, parent_job_id: j.parent_job_id.and_then(|s| s.parse::().ok()), @@ -1117,7 +1120,8 @@ impl JobManager { device_id, status, progress, - started_at: chrono::Utc::now(), // TODO: Get actual start time from DB + created_at: chrono::Utc::now(), // Running jobs use current time as fallback + started_at: Some(chrono::Utc::now()), // Running jobs have started completed_at: None, // Running jobs aren't completed yet error_message: None, // TODO: Get from handle if failed parent_job_id: None, // TODO: Get from DB if needed @@ -1157,7 +1161,8 @@ impl JobManager { device_id, status, progress, - started_at: j.started_at.unwrap_or(j.created_at), + created_at: j.created_at, + started_at: j.started_at, completed_at: j.completed_at, error_message: j.error_message, parent_job_id: j.parent_job_id.and_then(|s| s.parse::().ok()), diff --git a/core/src/infra/job/types.rs b/core/src/infra/job/types.rs index 6ff666ef6..83ef283ef 100644 --- a/core/src/infra/job/types.rs +++ b/core/src/infra/job/types.rs @@ -165,7 +165,8 @@ pub struct JobInfo { pub device_id: Uuid, // Device running this job pub status: JobStatus, pub progress: f32, - pub started_at: chrono::DateTime, + pub created_at: chrono::DateTime, + pub started_at: Option>, pub completed_at: Option>, pub error_message: Option, pub parent_job_id: Option, diff --git a/core/src/ops/jobs/info/output.rs b/core/src/ops/jobs/info/output.rs index 949af4c33..17929f9ca 100644 --- a/core/src/ops/jobs/info/output.rs +++ b/core/src/ops/jobs/info/output.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use specta::Type; use uuid::Uuid; @@ -8,7 +9,8 @@ pub struct JobInfoOutput { pub name: String, pub status: crate::infra::job::types::JobStatus, pub progress: f32, - pub started_at: chrono::DateTime, - pub completed_at: Option>, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, pub error_message: Option, } diff --git a/core/src/ops/jobs/info/query.rs b/core/src/ops/jobs/info/query.rs index aebb16579..c52148d9a 100644 --- a/core/src/ops/jobs/info/query.rs +++ b/core/src/ops/jobs/info/query.rs @@ -49,6 +49,7 @@ impl LibraryQuery for JobInfoQuery { name: j.name, status: j.status, progress: j.progress, + created_at: j.created_at, started_at: j.started_at, completed_at: j.completed_at, error_message: j.error_message, diff --git a/core/src/ops/jobs/list/output.rs b/core/src/ops/jobs/list/output.rs index bcae54ad8..8e1f25459 100644 --- a/core/src/ops/jobs/list/output.rs +++ b/core/src/ops/jobs/list/output.rs @@ -1,4 +1,5 @@ use crate::infra::job::types::ActionContextInfo; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use specta::Type; use uuid::Uuid; @@ -12,6 +13,9 @@ pub struct JobListItem { pub progress: f32, pub action_type: Option, pub action_context: Option, + pub created_at: DateTime, + pub started_at: Option>, + pub completed_at: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Type)] diff --git a/core/src/ops/jobs/list/query.rs b/core/src/ops/jobs/list/query.rs index 90d081f10..e5ca48dbe 100644 --- a/core/src/ops/jobs/list/query.rs +++ b/core/src/ops/jobs/list/query.rs @@ -56,6 +56,9 @@ impl LibraryQuery for JobListQuery { progress: j.progress, action_type: j.action_type, action_context: j.action_context, + created_at: j.created_at, + started_at: j.started_at, + completed_at: j.completed_at, }) .collect(); Ok(JobListOutput { jobs: items }) diff --git a/packages/interface/src/components/Explorer/context.tsx b/packages/interface/src/components/Explorer/context.tsx index 620a0978b..7b7d890e1 100644 --- a/packages/interface/src/components/Explorer/context.tsx +++ b/packages/interface/src/components/Explorer/context.tsx @@ -247,9 +247,18 @@ export function ExplorerProvider({ }, [devicesQuery.data]); const goBack = useCallback(() => { + console.log("[Explorer] goBack called:", { + historyIndex, + historyLength: history.length, + canGoBack: historyIndex > 0, + }); + if (historyIndex > 0) { const newIndex = historyIndex - 1; const entry = history[newIndex]; + + console.log("[Explorer] Going back to:", { newIndex, entry }); + setHistoryIndex(newIndex); if (entry.type === "path") { @@ -277,9 +286,18 @@ export function ExplorerProvider({ }, [historyIndex, history, navigate]); const goForward = useCallback(() => { + console.log("[Explorer] goForward called:", { + historyIndex, + historyLength: history.length, + canGoForward: historyIndex < history.length - 1, + }); + if (historyIndex < history.length - 1) { const newIndex = historyIndex + 1; const entry = history[newIndex]; + + console.log("[Explorer] Going forward to:", { newIndex, entry }); + setHistoryIndex(newIndex); if (entry.type === "path") { diff --git a/packages/interface/src/components/JobManager/JobsScreen/JobRow.tsx b/packages/interface/src/components/JobManager/JobsScreen/JobRow.tsx index ddc1889f2..cd59e0c25 100644 --- a/packages/interface/src/components/JobManager/JobsScreen/JobRow.tsx +++ b/packages/interface/src/components/JobManager/JobsScreen/JobRow.tsx @@ -6,137 +6,160 @@ import { getJobDisplayName, formatDuration, timeAgo } from "../types"; import { JobStatusIndicator } from "../components/JobStatusIndicator"; interface JobRowProps { - job: JobListItem; - onPause?: (jobId: string) => void; - onResume?: (jobId: string) => void; + job: JobListItem; + onPause?: (jobId: string) => void; + onResume?: (jobId: string) => void; } export function JobRow({ job, onPause, onResume }: JobRowProps) { - const [isHovered, setIsHovered] = useState(false); + const [isHovered, setIsHovered] = useState(false); - const displayName = getJobDisplayName(job); - const showActionButton = job.status === "running" || job.status === "paused"; - const canPause = job.status === "running" && onPause; - const canResume = job.status === "paused" && onResume; + const displayName = getJobDisplayName(job); + const showActionButton = + job.status === "running" || job.status === "paused"; + const canPause = job.status === "running" && onPause; + const canResume = job.status === "paused" && onResume; - const handleAction = (e: React.MouseEvent) => { - e.stopPropagation(); - if (canPause) { - onPause(job.id); - } else if (canResume) { - onResume(job.id); - } - }; + const handleAction = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canPause) { + onPause(job.id); + } else if (canResume) { + onResume(job.id); + } + }; - // Format progress percentage - const progressPercent = Math.round(job.progress * 100); + // Format progress percentage + const progressPercent = Math.round(job.progress * 100); - // Get phase and message - const phase = job.current_phase; - const message = job.status_message; + // Get phase and message + const phase = job.current_phase; + const message = job.status_message; - // Calculate duration - const duration = job.completed_at - ? new Date(job.completed_at).getTime() - new Date(job.created_at).getTime() - : Date.now() - new Date(job.created_at).getTime(); + // Calculate duration - prefer started_at for accuracy, fallback to created_at + const startTime = job.started_at || job.created_at; + const duration = startTime + ? job.completed_at + ? new Date(job.completed_at).getTime() - + new Date(startTime).getTime() + : Date.now() - new Date(startTime).getTime() + : 0; - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > - {/* Icon */} -
- -
+ return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {/* Icon */} +
+ +
- {/* Main info */} -
- {/* Job name and details */} -
-
-

- {displayName} -

- {phase && ( - - {phase} - - )} -
- {message && ( -

- {message} -

- )} -
+ {/* Main info */} +
+ {/* Job name and details */} +
+
+

+ {displayName} +

+ {phase && ( + + {phase} + + )} +
+ {message && ( +

+ {message} +

+ )} +
- {/* Progress */} - {(job.status === "running" || job.status === "paused") && ( -
-
-
-
-
- - {progressPercent}% - -
-
- )} + {/* Progress / Duration column */} +
+ {job.status === "running" || job.status === "paused" ? ( + // Show progress bar for active jobs +
+
+
+
+ + {progressPercent}% + +
+ ) : job.status === "completed" ? ( + // Show duration for completed jobs + + {formatDuration(duration)} + + ) : job.status === "queued" ? ( + // Show waiting status for queued jobs + + Waiting... + + ) : ( + // Show dash for failed/cancelled jobs + + )} +
- {/* Duration */} -
- - {formatDuration(duration)} - -
+ {/* Completed/Started time */} +
+ + {job.status === "completed" && job.completed_at + ? timeAgo(job.completed_at) + : job.status === "running" && job.started_at + ? timeAgo(job.started_at) + : job.created_at + ? timeAgo(job.created_at) + : "—"} + +
- {/* Created time */} -
- - {timeAgo(job.created_at)} - -
+ {/* Status */} +
+ + {job.status} + +
+
- {/* Status */} -
- - {job.status} - -
-
- - {/* Action button */} - {showActionButton && isHovered && (canPause || canResume) && ( - - )} -
- ); + {/* Action button */} + {showActionButton && isHovered && (canPause || canResume) && ( + + )} +
+ ); } diff --git a/packages/interface/src/components/JobManager/JobsScreen/index.tsx b/packages/interface/src/components/JobManager/JobsScreen/index.tsx index 2b75ae842..21492d224 100644 --- a/packages/interface/src/components/JobManager/JobsScreen/index.tsx +++ b/packages/interface/src/components/JobManager/JobsScreen/index.tsx @@ -6,151 +6,201 @@ import { useJobs } from "../hooks/useJobs"; import { JobRow } from "./JobRow"; export function JobsScreen() { - const navigate = useNavigate(); - const { jobs, pause, resume } = useJobs(); - const [showOnlyRunning, setShowOnlyRunning] = useState(false); + const navigate = useNavigate(); + const { jobs, pause, resume } = useJobs(); + const [showOnlyRunning, setShowOnlyRunning] = useState(false); - // Filter jobs based on toggle - const filteredJobs = showOnlyRunning - ? jobs.filter(job => job.status === "running" || job.status === "paused") - : jobs; + // Filter jobs based on toggle + const filteredJobs = showOnlyRunning + ? jobs.filter( + (job) => job.status === "running" || job.status === "paused", + ) + : jobs; - // Group jobs by status - const runningJobs = filteredJobs.filter(j => j.status === "running"); - const pausedJobs = filteredJobs.filter(j => j.status === "paused"); - const queuedJobs = filteredJobs.filter(j => j.status === "queued"); - const completedJobs = filteredJobs.filter(j => j.status === "completed"); - const failedJobs = filteredJobs.filter(j => j.status === "failed"); + // Group jobs by status + const runningJobs = filteredJobs.filter((j) => j.status === "running"); + const pausedJobs = filteredJobs.filter((j) => j.status === "paused"); + const queuedJobs = filteredJobs.filter((j) => j.status === "queued"); + const completedJobs = filteredJobs.filter((j) => j.status === "completed"); + const failedJobs = filteredJobs.filter((j) => j.status === "failed"); - return ( -
- {/* Header */} -
-
-
-

Jobs

-
- {jobs.length} total - {runningJobs.length > 0 && ( - <> - - {runningJobs.length} running - - )} -
-
+ return ( +
+ {/* Header */} +
+
+
+

Jobs

+
+ {jobs.length} total + {runningJobs.length > 0 && ( + <> + + {runningJobs.length} running + + )} +
+
-
- {/* Filter toggle */} - setShowOnlyRunning(!showOnlyRunning)} - title={showOnlyRunning ? "Show all jobs" : "Show only active jobs"} - /> +
+ {/* Filter toggle */} + setShowOnlyRunning(!showOnlyRunning)} + title={ + showOnlyRunning + ? "Show all jobs" + : "Show only active jobs" + } + /> - {/* Back button */} - navigate(-1)} - title="Go back" - /> -
-
+ {/* Back button */} + navigate(-1)} + title="Go back" + /> +
+
- {/* Column headers */} -
-
{/* Icon spacer */} -
-
Name
-
Progress
-
Duration
-
Created
-
Status
-
-
{/* Action button spacer */} -
-
+ {/* Column headers */} +
+
{/* Icon spacer */} +
+
Name
+
Duration
+
+ Time +
+
+ Status +
+
+
{" "} + {/* Action button spacer */} +
+
- {/* Content */} -
- {filteredJobs.length === 0 ? ( -
-
-

No jobs found

-
-
- ) : ( -
- {/* Running Jobs */} - {runningJobs.length > 0 && ( - - {runningJobs.map(job => ( - - ))} - - )} + {/* Content */} +
+ {filteredJobs.length === 0 ? ( +
+
+

+ No jobs found +

+
+
+ ) : ( +
+ {/* Running Jobs */} + {runningJobs.length > 0 && ( + + {runningJobs.map((job) => ( + + ))} + + )} - {/* Paused Jobs */} - {pausedJobs.length > 0 && ( - - {pausedJobs.map(job => ( - - ))} - - )} + {/* Paused Jobs */} + {pausedJobs.length > 0 && ( + + {pausedJobs.map((job) => ( + + ))} + + )} - {/* Queued Jobs */} - {queuedJobs.length > 0 && ( - - {queuedJobs.map(job => ( - - ))} - - )} + {/* Queued Jobs */} + {queuedJobs.length > 0 && ( + + {queuedJobs.map((job) => ( + + ))} + + )} - {/* Completed Jobs */} - {completedJobs.length > 0 && ( - - {completedJobs.map(job => ( - - ))} - - )} + {/* Completed Jobs */} + {completedJobs.length > 0 && ( + + {completedJobs.map((job) => ( + + ))} + + )} - {/* Failed Jobs */} - {failedJobs.length > 0 && ( - - {failedJobs.map(job => ( - - ))} - - )} -
- )} -
-
- ); + {/* Failed Jobs */} + {failedJobs.length > 0 && ( + + {failedJobs.map((job) => ( + + ))} + + )} +
+ )} +
+
+ ); } interface JobSectionProps { - title: string; - count: number; - children: React.ReactNode; + title: string; + count: number; + children: React.ReactNode; } function JobSection({ title, count, children }: JobSectionProps) { - return ( -
-
-

- {title} -

- ({count}) -
-
- {children} -
-
- ); + return ( +
+
+

+ {title} +

+ ({count}) +
+
{children}
+
+ ); } diff --git a/packages/interface/src/index.tsx b/packages/interface/src/index.tsx index 3fd83c2b8..3ddf4228a 100644 --- a/packages/interface/src/index.tsx +++ b/packages/interface/src/index.tsx @@ -13,6 +13,7 @@ export { LocationCacheDemo } from "./LocationCacheDemo"; export { Inspector, PopoutInspector } from "./Inspector"; export type { InspectorVariant } from "./Inspector"; export { QuickPreview } from "./components/QuickPreview"; +export { JobsScreen } from "./components/JobManager"; export { Settings } from "./Settings"; export { Spacedrop } from "./Spacedrop"; export { PairingModal } from "./components/PairingModal"; diff --git a/packages/interface/src/routes/overview/DevicePanel.tsx b/packages/interface/src/routes/overview/DevicePanel.tsx index a16dfdbc5..f9cdd081d 100644 --- a/packages/interface/src/routes/overview/DevicePanel.tsx +++ b/packages/interface/src/routes/overview/DevicePanel.tsx @@ -1,6 +1,12 @@ import { useState, useRef, useEffect } from "react"; import { motion } from "framer-motion"; -import { HardDrive, Plus, Database, CaretLeft, CaretRight } from "@phosphor-icons/react"; +import { + HardDrive, + Plus, + Database, + CaretLeft, + CaretRight, +} from "@phosphor-icons/react"; import Masonry from "react-masonry-css"; import DriveIcon from "@sd/assets/icons/Drive.png"; import HDDIcon from "@sd/assets/icons/HDD.png"; @@ -495,7 +501,9 @@ function LocationsScroller({
Date: Sun, 21 Dec 2025 20:00:22 +0000 Subject: [PATCH 05/12] Refactor: Use specific names for location, volume, and tag items Co-authored-by: ijamespine --- .../src/components/SpacesSidebar/hooks/spaceItemUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts b/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts index f171bf938..f6c2e8d11 100644 --- a/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts +++ b/packages/interface/src/components/SpacesSidebar/hooks/spaceItemUtils.ts @@ -91,9 +91,9 @@ function getItemLabel(itemType: ItemType, resolvedFile?: File | null): string { if (isRecentsItem(itemType)) return "Recents"; if (isFavoritesItem(itemType)) return "Favorites"; if (isFileKindsItem(itemType)) return "File Kinds"; - if (isLocationItem(itemType)) return "Location"; - if (isVolumeItem(itemType)) return "Volume"; - if (isTagItem(itemType)) return "Tag"; + if (isLocationItem(itemType)) return itemType.Location.name || "Unnamed Location"; + if (isVolumeItem(itemType)) return itemType.Volume.name || "Unnamed Volume"; + if (isTagItem(itemType)) return itemType.Tag.name || "Unnamed Tag"; if (isPathItem(itemType)) { // Use resolved file name if available, otherwise extract from path if (resolvedFile?.name) return resolvedFile.name; From 4764077f562973a619673680e9bb7b3fe7fae4e3 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 22 Dec 2025 02:48:16 -0800 Subject: [PATCH 06/12] Enhance job management functionality and UI - Added cancel job functionality to the job management system, allowing users to cancel running or paused jobs. - Updated job-related components (`JobRow`, `JobCard`, `JobList`) to include cancel action buttons, improving user interaction. - Integrated sound notifications for job completion, enhancing user feedback upon job status changes. - Refactored `useJobs` hook to manage job cancellation and sound playback, ensuring a cohesive experience across job management features. - Improved sidebar navigation by implementing `setSpaceItemIdFromSidebar` for better context handling during navigation events. --- core/src/library/mod.rs | 4 +- packages/assets/sounds/index.ts | 3 ++ packages/assets/sounds/job-done.mp3 | Bin 0 -> 27155 bytes packages/assets/sounds/job-done.ogg | Bin 0 -> 22742 bytes packages/interface/src/Explorer.tsx | 11 ---- .../src/components/Explorer/context.tsx | 22 +++++++- .../JobManager/JobManagerPopover.tsx | 7 ++- .../JobManager/JobsScreen/JobRow.tsx | 50 +++++++++++++----- .../JobManager/JobsScreen/index.tsx | 7 ++- .../JobManager/components/JobCard.tsx | 48 ++++++++++++----- .../JobManager/components/JobList.tsx | 5 +- .../components/JobManager/hooks/useJobs.ts | 21 ++++++++ .../components/SpacesSidebar/DevicesGroup.tsx | 7 ++- .../components/SpacesSidebar/SpaceItem.tsx | 8 +++ .../components/SpacesSidebar/TagsGroup.tsx | 5 ++ .../hooks/useSpaceItemContextMenu.ts | 12 ++++- 16 files changed, 161 insertions(+), 49 deletions(-) create mode 100644 packages/assets/sounds/job-done.mp3 create mode 100644 packages/assets/sounds/job-done.ogg diff --git a/core/src/library/mod.rs b/core/src/library/mod.rs index 3d65f4c76..fd789bade 100644 --- a/core/src/library/mod.rs +++ b/core/src/library/mod.rs @@ -1447,8 +1447,8 @@ impl Library { UPDATE content_kinds SET file_count = ( SELECT COUNT(*) - FROM content_identity - WHERE content_identity.kind_id = content_kinds.id + FROM content_identities + WHERE content_identities.kind_id = content_kinds.id ) "# .to_owned(), diff --git a/packages/assets/sounds/index.ts b/packages/assets/sounds/index.ts index f341dd402..459bcab32 100644 --- a/packages/assets/sounds/index.ts +++ b/packages/assets/sounds/index.ts @@ -8,6 +8,8 @@ import splatOgg from "./splat.ogg"; import splatMp3 from "./splat.mp3"; import splatTriggerOgg from "./splat-trigger.ogg"; import splatTriggerMp3 from "./splat-trigger.mp3"; +import jobDoneOgg from "./job-done.ogg"; +import jobDoneMp3 from "./job-done.mp3"; /** * Play a sound effect @@ -35,4 +37,5 @@ export const sounds = { pairing: () => playSound(pairingOgg, pairingMp3, 0.5), splat: () => playSound(splatOgg, splatMp3, 0.05), splatTrigger: () => playSound(splatTriggerOgg, splatTriggerMp3, 0.3), + jobDone: () => playSound(jobDoneOgg, jobDoneMp3, 0.4), }; diff --git a/packages/assets/sounds/job-done.mp3 b/packages/assets/sounds/job-done.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..ec0f4ce4d84bdd05653e358309bfdba79bde61a0 GIT binary patch literal 27155 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

~MJNQI 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 literal 0 HcmV?d00001 diff --git a/packages/assets/sounds/job-done.ogg b/packages/assets/sounds/job-done.ogg new file mode 100644 index 0000000000000000000000000000000000000000..8d0f1a188acd201d64160577e1b53028924d0a79 GIT binary patch literal 22742 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 z%0A5iIqXcUk`(|*0UOA6oGHK1sv^AiMXgN4&#VV5XFn@XkHTbwVywU4sK#i(M+pR% z+4~7bGV*zmh`!6p6n8s_QKSZ(eRPfO^a7Z-5Z!l8uVE`OI4GU31BVqV0KU)##?NeVvK8a zL@xD^zmFRUc=}D$+f5+5lf0*|Sn!&s7)KJF+F}mc`A2sfwrHr`w;v3WL#D^9iB1GY=nbmXxZM|YU z1&8W*(XZ=&@(D!bJj9Yc6Bs$+Q08CPBLr-Q&M-gx>oxU-^9+|PWJ{=zL&UwIz8fUE zzlc+5uUKG0NP77QD{pM+&qi0U>@z4gwOH@?pE|%^K{18A}0zZ8!dXp1dZduZrlNT9)E;mSljK9D-jVj=P|Rx%@t3DR)Wn zM9fm3>BXEX1HoHFDyk*qD{i)=PWt4`nu7tdT6DO9L=HqoM+sHtm_FRA zHfG+ZDcDqf4@nP{KejA>kxCx?HTPYu7UaI`rgb;gksKWVQ_$w4yZ8RNBhMM4&UY{9 z@c~Z_REq9NM>2C-bV%73&c(e6udg0ioScOLkKs^$>GpTG4F6B?rO49iTP@+nn9F&| z^Uzdp*w6w4ECvxROk7e`bEc5|-3&Q%OXos4)-*TqTS1)w40Yhxt}{C7!*9#tqQYM7jVF(UJ&R`FB(jNnJy&tz$q z_rQ1w8DoIm_O(rKOfG5Ajnvxs;maYW6n*ioyK{`Y!_S$`f#PZ149x0k5jFeEvGK9N pUS%6UeZqv*U`_x { - const spaceItemKey = getSpaceItemKeyFromRoute( - location.pathname, - location.search, - ); - setSpaceItemId(spaceItemKey); - }, [location.pathname, location.search, setSpaceItemId]); - // Check if we're on Overview (hide inspector) or in Knowledge view (has its own inspector) const isOverview = location.pathname === "/"; const isKnowledgeView = viewMode === "knowledge"; diff --git a/packages/interface/src/components/Explorer/context.tsx b/packages/interface/src/components/Explorer/context.tsx index 7b7d890e1..b44f72ef5 100644 --- a/packages/interface/src/components/Explorer/context.tsx +++ b/packages/interface/src/components/Explorer/context.tsx @@ -5,6 +5,7 @@ import { useMemo, useEffect, useCallback, + useRef, type ReactNode, } from "react"; import { useNavigate } from "react-router-dom"; @@ -115,6 +116,9 @@ interface ExplorerState { devices: Map; setSpaceItemId: (id: string) => void; + + // Set space item ID and trigger preference loading (use when navigating from sidebar) + setSpaceItemIdFromSidebar: (id: string) => void; } const ExplorerContext = createContext(null); @@ -136,6 +140,8 @@ export function ExplorerProvider({ const [spaceItemIdInternal, setSpaceItemIdInternal] = useState( initialSpaceItemId || "default", ); + // Track if the next spaceItemId change should load preferences + const shouldLoadPreferencesRef = useRef(false); const [currentPath, setCurrentPathInternal] = useState(null); const [currentView, setCurrentView] = useState(null); const [history, setHistory] = useState([]); @@ -164,8 +170,14 @@ export function ExplorerProvider({ const spaceItemKey = spaceItemIdInternal; const pathKey = getPathKey(currentPath); - // Load view preferences when space item changes + // Load view preferences only when navigation originates from sidebar useEffect(() => { + // Only load preferences when explicitly requested (sidebar navigation) + if (!shouldLoadPreferencesRef.current) { + return; + } + shouldLoadPreferencesRef.current = false; + const prefs = viewPrefs.getPreferences(spaceItemKey); if (prefs) { setViewModeInternal(prefs.viewMode); @@ -234,6 +246,12 @@ export function ExplorerProvider({ [spaceItemKey], ); + // Set space item ID from sidebar navigation (triggers preference loading) + const setSpaceItemIdFromSidebar = useCallback((id: string) => { + shouldLoadPreferencesRef.current = true; + setSpaceItemIdInternal(id); + }, []); + // Use normalized query for automatic updates when device events are emitted const devicesQuery = useNormalizedQuery({ wireMethod: "query:devices.list", @@ -434,6 +452,7 @@ export function ExplorerProvider({ setTagModeActive, devices, setSpaceItemId: setSpaceItemIdInternal, + setSpaceItemIdFromSidebar, }), [ currentPath, @@ -462,6 +481,7 @@ export function ExplorerProvider({ currentFiles, tagModeActive, devices, + setSpaceItemIdFromSidebar, ], ); diff --git a/packages/interface/src/components/JobManager/JobManagerPopover.tsx b/packages/interface/src/components/JobManager/JobManagerPopover.tsx index 5e447ed0a..1b06d06b3 100644 --- a/packages/interface/src/components/JobManager/JobManagerPopover.tsx +++ b/packages/interface/src/components/JobManager/JobManagerPopover.tsx @@ -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 } = useJobs(); + const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel } = useJobs(); // Reset filter to "active only" when popover opens useEffect(() => { @@ -97,6 +97,7 @@ export function JobManagerPopover({ className }: JobManagerPopoverProps) { setShowOnlyRunning={setShowOnlyRunning} pause={pause} resume={resume} + cancel={cancel} /> )} @@ -109,12 +110,14 @@ function JobManagerPopoverContent({ setShowOnlyRunning, pause, resume, + cancel, }: { jobs: any[]; showOnlyRunning: boolean; setShowOnlyRunning: (value: boolean) => void; pause: (jobId: string) => Promise; resume: (jobId: string) => Promise; + cancel: (jobId: string) => Promise; }) { const filteredJobs = showOnlyRunning ? jobs.filter((job) => job.status === "running" || job.status === "paused") @@ -132,7 +135,7 @@ function JobManagerPopoverContent({ }} transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }} > - + ); } diff --git a/packages/interface/src/components/JobManager/JobsScreen/JobRow.tsx b/packages/interface/src/components/JobManager/JobsScreen/JobRow.tsx index cd59e0c25..9080778b3 100644 --- a/packages/interface/src/components/JobManager/JobsScreen/JobRow.tsx +++ b/packages/interface/src/components/JobManager/JobsScreen/JobRow.tsx @@ -1,4 +1,4 @@ -import { Pause, Play } from "@phosphor-icons/react"; +import { Pause, Play, X } from "@phosphor-icons/react"; import { useState } from "react"; import clsx from "clsx"; import type { JobListItem } from "../types"; @@ -9,9 +9,10 @@ interface JobRowProps { job: JobListItem; onPause?: (jobId: string) => void; onResume?: (jobId: string) => void; + onCancel?: (jobId: string) => void; } -export function JobRow({ job, onPause, onResume }: JobRowProps) { +export function JobRow({ job, onPause, onResume, onCancel }: JobRowProps) { const [isHovered, setIsHovered] = useState(false); const displayName = getJobDisplayName(job); @@ -19,6 +20,7 @@ export function JobRow({ job, onPause, onResume }: JobRowProps) { job.status === "running" || job.status === "paused"; const canPause = job.status === "running" && onPause; const canResume = job.status === "paused" && onResume; + const canCancel = (job.status === "running" || job.status === "paused") && onCancel; const handleAction = (e: React.MouseEvent) => { e.stopPropagation(); @@ -29,6 +31,13 @@ export function JobRow({ job, onPause, onResume }: JobRowProps) { } }; + const handleCancel = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canCancel) { + onCancel(job.id); + } + }; + // Format progress percentage const progressPercent = Math.round(job.progress * 100); @@ -146,19 +155,32 @@ export function JobRow({ job, onPause, onResume }: JobRowProps) {

- {/* Action button */} - {showActionButton && isHovered && (canPause || canResume) && ( - )} - + {canCancel && ( + + )} +
)}
); diff --git a/packages/interface/src/components/JobManager/JobsScreen/index.tsx b/packages/interface/src/components/JobManager/JobsScreen/index.tsx index 21492d224..1ffe25f6e 100644 --- a/packages/interface/src/components/JobManager/JobsScreen/index.tsx +++ b/packages/interface/src/components/JobManager/JobsScreen/index.tsx @@ -7,7 +7,7 @@ import { JobRow } from "./JobRow"; export function JobsScreen() { const navigate = useNavigate(); - const { jobs, pause, resume } = useJobs(); + const { jobs, pause, resume, cancel } = useJobs(); const [showOnlyRunning, setShowOnlyRunning] = useState(false); // Filter jobs based on toggle @@ -106,6 +106,7 @@ export function JobsScreen() { job={job} onPause={pause} onResume={resume} + onCancel={cancel} /> ))} @@ -123,6 +124,7 @@ export function JobsScreen() { job={job} onPause={pause} onResume={resume} + onCancel={cancel} /> ))} @@ -140,6 +142,7 @@ export function JobsScreen() { job={job} onPause={pause} onResume={resume} + onCancel={cancel} /> ))} @@ -157,6 +160,7 @@ export function JobsScreen() { job={job} onPause={pause} onResume={resume} + onCancel={cancel} /> ))} @@ -174,6 +178,7 @@ export function JobsScreen() { job={job} onPause={pause} onResume={resume} + onCancel={cancel} /> ))} diff --git a/packages/interface/src/components/JobManager/components/JobCard.tsx b/packages/interface/src/components/JobManager/components/JobCard.tsx index 65a9efe30..9edf1896d 100644 --- a/packages/interface/src/components/JobManager/components/JobCard.tsx +++ b/packages/interface/src/components/JobManager/components/JobCard.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Pause, Play } from "@phosphor-icons/react"; +import { Pause, Play, X } from "@phosphor-icons/react"; import clsx from "clsx"; import type { JobListItem } from "../types"; import { @@ -15,9 +15,10 @@ interface JobCardProps { job: JobListItem; onPause?: (jobId: string) => void; onResume?: (jobId: string) => void; + onCancel?: (jobId: string) => void; } -export function JobCard({ job, onPause, onResume }: JobCardProps) { +export function JobCard({ job, onPause, onResume, onCancel }: JobCardProps) { const [isHovered, setIsHovered] = useState(false); const displayName = getJobDisplayName(job); @@ -27,6 +28,7 @@ export function JobCard({ job, onPause, onResume }: JobCardProps) { const showActionButton = job.status === "running" || job.status === "paused"; const canPause = job.status === "running" && onPause; const canResume = job.status === "paused" && onResume; + const canCancel = (job.status === "running" || job.status === "paused") && onCancel; const handleAction = (e: React.MouseEvent) => { e.stopPropagation(); @@ -37,6 +39,13 @@ export function JobCard({ job, onPause, onResume }: JobCardProps) { } }; + const handleCancel = (e: React.MouseEvent) => { + e.stopPropagation(); + if (canCancel) { + onCancel(job.id); + } + }; + return (
- {showActionButton && isHovered && (canPause || canResume) && ( - )} - + {canCancel && ( + + )} +
)}
diff --git a/packages/interface/src/components/JobManager/components/JobList.tsx b/packages/interface/src/components/JobManager/components/JobList.tsx index 7806cfa2a..b9e9b954d 100644 --- a/packages/interface/src/components/JobManager/components/JobList.tsx +++ b/packages/interface/src/components/JobManager/components/JobList.tsx @@ -7,9 +7,10 @@ interface JobListProps { jobs: JobListItem[]; onPause?: (jobId: string) => void; onResume?: (jobId: string) => void; + onCancel?: (jobId: string) => void; } -export function JobList({ jobs, onPause, onResume }: JobListProps) { +export function JobList({ jobs, onPause, onResume, onCancel }: JobListProps) { if (jobs.length === 0) { return ; } @@ -25,7 +26,7 @@ export function JobList({ jobs, onPause, onResume }: JobListProps) { exit={{ opacity: 0, x: -10 }} transition={{ duration: 0.15, ease: [0.25, 1, 0.5, 1] }} > - + ))} diff --git a/packages/interface/src/components/JobManager/hooks/useJobs.ts b/packages/interface/src/components/JobManager/hooks/useJobs.ts index 227860990..7bda47fd7 100644 --- a/packages/interface/src/components/JobManager/hooks/useJobs.ts +++ b/packages/interface/src/components/JobManager/hooks/useJobs.ts @@ -1,6 +1,11 @@ import { useState, useEffect, useRef, useMemo } from "react"; import { useLibraryQuery, useLibraryMutation, useSpacedriveClient } from "../../../context"; import type { JobListItem } from "../types"; +import { sounds } from "@sd/assets/sounds"; + +// Global set to track which jobs have already played their completion sound +// This prevents multiple hook instances from playing the sound multiple times +const completedJobSounds = new Set(); /** * Unified hook for job management and counting. @@ -20,6 +25,7 @@ export function useJobs() { const pauseMutation = useLibraryMutation("jobs.pause"); const resumeMutation = useLibraryMutation("jobs.resume"); + const cancelMutation = useLibraryMutation("jobs.cancel"); // Ref for stable refetch access const refetchRef = useRef(refetch); @@ -44,6 +50,16 @@ export function useJobs() { if ("JobQueued" in event || "JobStarted" in event || "JobCompleted" in event || "JobFailed" in event || "JobPaused" in event || "JobResumed" in event || "JobCancelled" in event) { + if ("JobCompleted" in event) { + const jobId = event.JobCompleted?.job_id; + if (jobId && !completedJobSounds.has(jobId)) { + completedJobSounds.add(jobId); + sounds.jobDone(); + + // Clean up old entries after 5 seconds to prevent memory leak + setTimeout(() => completedJobSounds.delete(jobId), 5000); + } + } refetchRef.current(); } else if ("JobProgress" in event) { const progressData = event.JobProgress; @@ -95,6 +111,10 @@ export function useJobs() { await resumeMutation.mutateAsync({ job_id: jobId }); }; + const cancel = async (jobId: string) => { + await cancelMutation.mutateAsync({ job_id: jobId }); + }; + const runningCount = jobs.filter((j) => j.status === "running").length; const pausedCount = jobs.filter((j) => j.status === "paused").length; @@ -104,6 +124,7 @@ export function useJobs() { hasRunningJobs: runningCount > 0, pause, resume, + cancel, isLoading, error, }; diff --git a/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx b/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx index 6ee4a7a8e..7219d3129 100644 --- a/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx @@ -18,7 +18,7 @@ export function DevicesGroup({ sortableAttributes, sortableListeners, }: DevicesGroupProps) { - const { navigateToView } = useExplorer(); + const { navigateToView, setSpaceItemIdFromSidebar } = useExplorer(); // Use normalized query for automatic updates when device events are emitted const { data: devices, isLoading } = useNormalizedQuery< @@ -106,7 +106,10 @@ export function DevicesGroup({ item={deviceItem as any} customIcon={getDeviceIcon(device)} customLabel={device.name} - onClick={() => navigateToView("device", device.id)} + onClick={() => { + setSpaceItemIdFromSidebar(`device:${device.id}`); + navigateToView("device", device.id); + }} onContextMenu={handleDeviceContextMenu(device)} allowInsertion={false} isLastItem={index === devices.length - 1} diff --git a/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx b/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx index b10b40f44..b0260c537 100644 --- a/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx +++ b/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx @@ -13,6 +13,7 @@ import { import { useSpaceItemActive } from "./hooks/useSpaceItemActive"; import { useSpaceItemDropZones } from "./hooks/useSpaceItemDropZones"; import { useSpaceItemContextMenu } from "./hooks/useSpaceItemContextMenu"; +import { useExplorer, getSpaceItemKeyFromRoute } from "../Explorer/context"; // Overrides for customizing item appearance and behavior export interface SpaceItemOverrides { @@ -166,6 +167,7 @@ export function SpaceItem({ className, }: SpaceItemProps) { const navigate = useNavigate(); + const { setSpaceItemIdFromSidebar } = useExplorer(); // Merge legacy props into overrides const effectiveOverrides: SpaceItemOverrides = { @@ -237,6 +239,12 @@ export function SpaceItem({ if (effectiveOverrides.onClick) { effectiveOverrides.onClick(e); } else if (path) { + // Extract pathname and search from the path + const [pathname, search] = path.includes("?") + ? [path.split("?")[0], "?" + path.split("?")[1]] + : [path, ""]; + const spaceItemKey = getSpaceItemKeyFromRoute(pathname, search); + setSpaceItemIdFromSidebar(spaceItemKey); navigate(path); } }; diff --git a/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx b/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx index d5812e2c6..f87016278 100644 --- a/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import { useNormalizedQuery, useLibraryMutation } from '../../context'; import type { Tag } from '@sd/ts-client'; import { GroupHeader } from './GroupHeader'; +import { useExplorer } from '../Explorer/context'; interface TagsGroupProps { isCollapsed: boolean; @@ -20,6 +21,7 @@ interface TagItemProps { function TagItem({ tag, depth = 0 }: TagItemProps) { const navigate = useNavigate(); + const { setSpaceItemIdFromSidebar } = useExplorer(); const [isExpanded, setIsExpanded] = useState(false); // TODO: Fetch children when hierarchy is implemented @@ -27,6 +29,7 @@ function TagItem({ tag, depth = 0 }: TagItemProps) { const hasChildren = children.length > 0; const handleClick = () => { + setSpaceItemIdFromSidebar(`tag:${tag.id}`); navigate(`/tag/${tag.id}`); }; @@ -88,6 +91,7 @@ export function TagsGroup({ sortableListeners, }: TagsGroupProps) { const navigate = useNavigate(); + const { setSpaceItemIdFromSidebar } = useExplorer(); const [isCreating, setIsCreating] = useState(false); const [newTagName, setNewTagName] = useState(''); @@ -115,6 +119,7 @@ export function TagsGroup({ // Navigate to the new tag if (result?.tag?.id) { + setSpaceItemIdFromSidebar(`tag:${result.tag.id}`); navigate(`/tag/${result.tag.id}`); } diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts index 615c228cc..409523629 100644 --- a/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts +++ b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts @@ -14,6 +14,7 @@ import { import { usePlatform } from "../../../platform"; import { useLibraryMutation } from "../../../context"; import { isVolumeItem, isPathItem } from "./spaceItemUtils"; +import { useExplorer, getSpaceItemKeyFromRoute } from "../../Explorer/context"; interface UseSpaceItemContextMenuOptions { item: SpaceItemType; @@ -37,6 +38,7 @@ export function useSpaceItemContextMenu({ }: UseSpaceItemContextMenuOptions): ContextMenuResult { const navigate = useNavigate(); const platform = usePlatform(); + const { setSpaceItemIdFromSidebar } = useExplorer(); const deleteItem = useLibraryMutation("spaces.delete_item"); const indexVolume = useLibraryMutation("volumes.index"); @@ -45,7 +47,15 @@ export function useSpaceItemContextMenu({ icon: FolderOpen, label: "Open", onClick: () => { - if (path) navigate(path); + if (path) { + // Extract pathname and search from the path + const [pathname, search] = path.includes("?") + ? [path.split("?")[0], "?" + path.split("?")[1]] + : [path, ""]; + const spaceItemKey = getSpaceItemKeyFromRoute(pathname, search); + setSpaceItemIdFromSidebar(spaceItemKey); + navigate(path); + } }, condition: () => !!path, }, From 1832d929eabed8397f5408fd477890ed724247e0 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 22 Dec 2025 04:19:07 -0800 Subject: [PATCH 07/12] Implement device unpairing and cache management - Added `remove_paired_device_from_cache` method to `DeviceManager` for removing unpaired devices from the cache. - Enhanced `DeviceRevokeAction` to handle device revocation, including detailed logging for each step of the removal process from network registry, persistence, and DeviceManager cache. - Updated `DevicePersistence` to ensure proper deletion of device keys from KeyManager and improved logging for device removal. - Refactored `DeviceRegistry` to clean up node-to-device and session mappings upon device removal. - Introduced integration tests to verify the complete lifecycle of device operations, ensuring unpaired devices are correctly removed from all caches and persistent storage. --- core/src/device/manager.rs | 15 + core/src/ops/network/revoke/action.rs | 109 +++- .../src/service/network/device/persistence.rs | 30 + core/src/service/network/device/registry.rs | 17 +- core/tests/device_operation_test.rs | 523 ++++++++++++++++++ 5 files changed, 687 insertions(+), 7 deletions(-) create mode 100644 core/tests/device_operation_test.rs diff --git a/core/src/device/manager.rs b/core/src/device/manager.rs index 2be0e11c8..07e67dd2c 100644 --- a/core/src/device/manager.rs +++ b/core/src/device/manager.rs @@ -295,6 +295,21 @@ impl DeviceManager { Ok(()) } + /// Remove a specific paired device from the cache by device ID + /// Used when a device is unpaired/revoked + pub fn remove_paired_device_from_cache(&self, device_id: Uuid) -> Result<(), DeviceError> { + let mut cache = self + .paired_device_cache + .write() + .map_err(|_| DeviceError::LockPoisoned)?; + + // Find and remove the device by its ID (search by value) + cache.retain(|_slug, &mut cached_id| cached_id != device_id); + + tracing::debug!("Removed device {} from DeviceManager cache", device_id); + Ok(()) + } + /// Get the current device as a domain Device object pub async fn current_device(&self) -> Device { let config = self.config.read().unwrap(); diff --git a/core/src/ops/network/revoke/action.rs b/core/src/ops/network/revoke/action.rs index 5df086516..e54819605 100644 --- a/core/src/ops/network/revoke/action.rs +++ b/core/src/ops/network/revoke/action.rs @@ -20,22 +20,125 @@ impl CoreAction for DeviceRevokeAction { self, context: Arc, ) -> std::result::Result { + tracing::info!("Revoking device: {}", self.device_id); + let net = context .get_networking() .await .ok_or_else(|| ActionError::Internal("Networking not initialized".to_string()))?; - // Remove from registry state and persistence + + // Remove from network registry state and persistence { let reg = net.device_registry(); let mut guard = reg.write().await; - let _ = guard.remove_device(self.device_id); - let _ = guard.remove_paired_device(self.device_id).await; + + tracing::info!( + "Removing device {} from network registry in-memory state", + self.device_id + ); + if let Err(e) = guard.remove_device(self.device_id) { + tracing::warn!("Failed to remove device from network registry: {}", e); + } + + tracing::info!( + "Removing device {} from network encrypted persistence", + self.device_id + ); + match guard.remove_paired_device(self.device_id).await { + Ok(removed) => { + if removed { + tracing::info!( + "Device {} removed from network persistent storage", + self.device_id + ); + } else { + tracing::warn!( + "Device {} not found in network persistent storage (already removed?)", + self.device_id + ); + } + } + Err(e) => { + tracing::error!("Failed to remove device from network persistence: {}", e); + return Err(ActionError::Internal(format!( + "Failed to remove device from network persistence: {}", + e + ))); + } + } + } + + // Remove from all library databases + tracing::info!("Removing device {} from library databases", self.device_id); + let libraries = context.libraries().await; + let mut removed_from_libraries = 0; + + for library in libraries.get_open_libraries().await { + let db = library.db().conn(); + + // Delete device from library database + use crate::infra::db::entities::device; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + match device::Entity::delete_many() + .filter(device::Column::Uuid.eq(self.device_id)) + .exec(db) + .await + { + Ok(result) => { + if result.rows_affected > 0 { + tracing::info!( + "Device {} removed from library {} database", + self.device_id, + library.id() + ); + removed_from_libraries += 1; + } + } + Err(e) => { + tracing::warn!( + "Failed to remove device from library {} database: {}", + library.id(), + e + ); + } + } + } + + if removed_from_libraries > 0 { + tracing::info!( + "Device {} removed from {} library database(s)", + self.device_id, + removed_from_libraries + ); + } else { + tracing::warn!( + "Device {} not found in any library databases (may have been removed already)", + self.device_id + ); + } + + // Remove from DeviceManager cache + tracing::info!( + "Removing device {} from DeviceManager cache", + self.device_id + ); + if let Err(e) = context + .device_manager + .remove_paired_device_from_cache(self.device_id) + { + tracing::warn!("Failed to remove device from cache: {}", e); } // Emit ResourceDeleted event + tracing::info!( + "Emitting ResourceDeleted event for device {}", + self.device_id + ); use crate::domain::resource::EventEmitter; crate::domain::device::Device::emit_deleted(self.device_id, &context.events); + tracing::info!("Device {} successfully revoked", self.device_id); Ok(DeviceRevokeOutput { revoked: true }) } diff --git a/core/src/service/network/device/persistence.rs b/core/src/service/network/device/persistence.rs index 98a96f7c2..de741e8a1 100644 --- a/core/src/service/network/device/persistence.rs +++ b/core/src/service/network/device/persistence.rs @@ -192,11 +192,41 @@ impl DevicePersistence { /// Remove a paired device pub async fn remove_paired_device(&self, device_id: Uuid) -> Result { + tracing::debug!( + "Attempting to remove paired device {} from persistence", + device_id + ); + let mut devices = self.load_paired_devices().await?; let removed = devices.remove(&device_id).is_some(); if removed { + tracing::info!("Device {} found in paired devices, removing...", device_id); + + // Delete the individual device key from KeyManager + let key = Self::device_key(device_id); + tracing::debug!("Deleting device key '{}' from KeyManager", key); + + if let Err(e) = self.key_manager.delete_secret(&key).await { + tracing::warn!("Failed to delete device key {}: {}", key, e); + } else { + tracing::info!("Device key '{}' deleted from KeyManager", key); + } + + // Update the device list (removes from paired_devices_list) + tracing::debug!( + "Updating paired devices list (now {} devices)", + devices.len() + ); self.save_paired_devices(&devices).await?; + + tracing::info!( + "Device {} successfully removed from persistence ({} devices remaining)", + device_id, + devices.len() + ); + } else { + tracing::warn!("Device {} not found in paired devices list", device_id); } Ok(removed) diff --git a/core/src/service/network/device/registry.rs b/core/src/service/network/device/registry.rs index bbe2e19c9..5d4040606 100644 --- a/core/src/service/network/device/registry.rs +++ b/core/src/service/network/device/registry.rs @@ -439,15 +439,24 @@ impl DeviceRegistry { /// Remove a device from the registry pub fn remove_device(&mut self, device_id: Uuid) -> Result<()> { if let Some(state) = self.devices.remove(&device_id) { - // Clean up mappings + // Clean up node-to-device mappings for all states match &state { DeviceState::Discovered { node_id, .. } | DeviceState::Pairing { node_id, .. } => { self.node_to_device.remove(node_id); } - DeviceState::Pairing { session_id, .. } => { - self.session_to_device.remove(session_id); + DeviceState::Paired { info, .. } + | DeviceState::Connected { info, .. } + | DeviceState::Disconnected { info, .. } => { + // Extract node ID from network fingerprint and clean up mapping + if let Ok(node_id) = info.network_fingerprint.node_id.parse::() { + self.node_to_device.remove(&node_id); + } } - _ => {} + } + + // Clean up session-to-device mapping for pairing state + if let DeviceState::Pairing { session_id, .. } = &state { + self.session_to_device.remove(session_id); } } diff --git a/core/tests/device_operation_test.rs b/core/tests/device_operation_test.rs new file mode 100644 index 000000000..d5889ff8d --- /dev/null +++ b/core/tests/device_operation_test.rs @@ -0,0 +1,523 @@ +//! Device operation integration test +//! +//! This test verifies the complete lifecycle of device operations: +//! 1. Two devices pair successfully +//! 2. Device unpair/revoke operation works correctly +//! 3. Unpaired device is removed from all caches and persistent storage +//! 4. ResourceDeleted event is emitted +//! 5. Unpaired device doesn't reappear after restart +//! +//! Tests the full cleanup flow: +//! - DeviceRegistry in-memory state +//! - DevicePersistence (encrypted KeyManager storage) +//! - DeviceManager paired_device_cache +//! - Node-to-device mappings +//! - Event emission + +use sd_core::testing::CargoTestRunner; +use sd_core::Core; +use std::env; +use std::path::PathBuf; +use std::time::Duration; +use tokio::time::timeout; + +/// Alice's device operation scenario - pairs with Bob, then unpairs +#[tokio::test] +#[ignore] // Only run when explicitly called via subprocess +async fn alice_device_ops_scenario() { + let role = env::var("TEST_ROLE").unwrap_or_default(); + if !role.starts_with("alice") { + return; + } + + let data_dir = PathBuf::from("/tmp/spacedrive-device-ops-test/alice"); + let device_name = "Alice's Device"; + + // Set test directory for file-based discovery + env::set_var("SPACEDRIVE_TEST_DIR", "/tmp/spacedrive-device-ops-test"); + + // Determine which phase we're in + let is_restart = role == "alice_restart"; + + if is_restart { + println!("Alice: RESTART PHASE - Verifying unpaired device stays gone"); + println!("Alice: Data dir: {:?}", data_dir); + + // Initialize Core + println!("Alice: Initializing Core after restart..."); + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + println!("Alice: Core initialized successfully"); + + // Initialize networking + println!("Alice: Initializing networking..."); + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + + // Give time for any potential auto-reconnection + tokio::time::sleep(Duration::from_secs(5)).await; + + // Verify Bob is NOT in paired devices list + if let Some(networking) = core.networking() { + let registry = networking.device_registry(); + let guard = registry.read().await; + let paired_devices = guard.get_paired_devices(); + + println!( + "Alice: After restart, paired devices count: {}", + paired_devices.len() + ); + + // Should have NO paired devices after unpair + restart + assert_eq!( + paired_devices.len(), + 0, + "Unpaired device reappeared after restart! Found {} devices", + paired_devices.len() + ); + + println!("Alice: ✓ Verified unpaired device stayed removed after restart"); + } + + // Verify Bob is NOT in connected devices + let connected_devices = core.services.device.get_connected_devices().await.unwrap(); + assert_eq!( + connected_devices.len(), + 0, + "Unpaired device reconnected! Found {} connected devices", + connected_devices.len() + ); + + println!("Alice: ✓ Verified no devices reconnected"); + + // Write success marker + std::fs::write( + "/tmp/spacedrive-device-ops-test/alice_restart_success.txt", + "success", + ) + .unwrap(); + + println!("Alice: Restart phase completed successfully"); + return; + } + + // INITIAL PHASE: Pair with Bob, then unpair + println!("Alice: INITIAL PHASE - Pairing and unpairing"); + println!("Alice: Data dir: {:?}", data_dir); + + // Initialize Core + println!("Alice: Initializing Core..."); + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + println!("Alice: Core initialized successfully"); + + // Set device name + core.device.set_name(device_name.to_string()).unwrap(); + + // Initialize networking + println!("Alice: Initializing networking..."); + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + + tokio::time::sleep(Duration::from_secs(3)).await; + println!("Alice: Networking initialized successfully"); + + // Start pairing as initiator + println!("Alice: Starting pairing as initiator..."); + let (pairing_code, expires_in) = if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(15), + networking.start_pairing_as_initiator(false), + ) + .await + .unwrap() + .unwrap() + } else { + panic!("Networking not initialized"); + }; + + let short_code = pairing_code + .split_whitespace() + .take(3) + .collect::>() + .join(" "); + println!( + "Alice: Pairing code generated: {}... (expires in {}s)", + short_code, expires_in + ); + + // Write pairing code for Bob + std::fs::create_dir_all("/tmp/spacedrive-device-ops-test").unwrap(); + std::fs::write( + "/tmp/spacedrive-device-ops-test/pairing_code.txt", + &pairing_code, + ) + .unwrap(); + + // Wait for pairing completion + println!("Alice: Waiting for pairing to complete..."); + let mut bob_device_id = None; + let mut attempts = 0; + let max_attempts = 45; + + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + + let connected_devices = core.services.device.get_connected_devices().await.unwrap(); + if !connected_devices.is_empty() { + println!("Alice: Pairing completed successfully!"); + + let device_info = core + .services + .device + .get_connected_devices_info() + .await + .unwrap(); + + for device in &device_info { + println!( + "Alice paired with: {} (ID: {})", + device.device_name, device.device_id + ); + if device.device_name.contains("Bob") { + bob_device_id = Some(device.device_id); + } + } + + assert!( + bob_device_id.is_some(), + "Bob's device not found in paired devices" + ); + break; + } + + attempts += 1; + if attempts >= max_attempts { + panic!("Alice: Pairing timeout"); + } + } + + let bob_id = bob_device_id.unwrap(); + println!("Alice: Bob's device ID: {}", bob_id); + + // Give Bob time to also detect the connection + tokio::time::sleep(Duration::from_secs(3)).await; + + // Now UNPAIR Bob + println!("Alice: Unpairing Bob's device..."); + + if let Some(networking) = core.networking() { + // Verify Bob is in paired devices before unpair + { + let registry = networking.device_registry(); + let guard = registry.read().await; + let paired_before = guard.get_paired_devices(); + println!( + "Alice: Paired devices before unpair: {}", + paired_before.len() + ); + assert_eq!( + paired_before.len(), + 1, + "Should have exactly 1 paired device" + ); + } + + // Execute unpair by calling registry methods directly (same as DeviceRevokeAction) + let registry = networking.device_registry(); + let result = { + let mut guard = registry.write().await; + guard.remove_device(bob_id).unwrap(); + guard.remove_paired_device(bob_id).await.unwrap() + }; + + println!("Alice: Unpair result: removed={}", result); + assert!(result, "Unpair operation failed - device not found"); + + // Remove from DeviceManager cache (same as action does) + if let Err(e) = core.device.remove_paired_device_from_cache(bob_id) { + println!("Alice: Warning - failed to remove from cache: {}", e); + } + + // Give time for cleanup to complete + tokio::time::sleep(Duration::from_secs(2)).await; + + // Verify Bob is removed from paired devices + { + let registry = networking.device_registry(); + let guard = registry.read().await; + let paired_after = guard.get_paired_devices(); + println!("Alice: Paired devices after unpair: {}", paired_after.len()); + assert_eq!( + paired_after.len(), + 0, + "Device still in paired list after unpair!" + ); + } + + println!("Alice: ✓ Verified device removed from registry"); + + // Verify Bob is removed from DeviceManager cache + let device_by_slug = core.device.resolve_by_slug("bobs-test-device"); + assert!( + device_by_slug.is_none(), + "Device still in DeviceManager cache after unpair!" + ); + println!("Alice: ✓ Verified device removed from DeviceManager cache"); + + // Verify Bob disconnected + let connected_after = core.services.device.get_connected_devices().await.unwrap(); + assert_eq!( + connected_after.len(), + 0, + "Device still connected after unpair!" + ); + println!("Alice: ✓ Verified device disconnected"); + } + + // Write success marker + std::fs::write( + "/tmp/spacedrive-device-ops-test/alice_success.txt", + "success", + ) + .unwrap(); + + println!("Alice: Initial phase completed successfully"); +} + +/// Bob's device operation scenario - pairs with Alice, gets unpaired +#[tokio::test] +#[ignore] // Only run when explicitly called via subprocess +async fn bob_device_ops_scenario() { + if env::var("TEST_ROLE").unwrap_or_default() != "bob" { + return; + } + + let data_dir = PathBuf::from("/tmp/spacedrive-device-ops-test/bob"); + let device_name = "Bob's Test Device"; + + // Set test directory for file-based discovery + env::set_var("SPACEDRIVE_TEST_DIR", "/tmp/spacedrive-device-ops-test"); + + println!("Bob: Starting pairing scenario"); + println!("Bob: Data dir: {:?}", data_dir); + + // Initialize Core + println!("Bob: Initializing Core..."); + let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) + .await + .unwrap() + .unwrap(); + println!("Bob: Core initialized successfully"); + + // Set device name + core.device.set_name(device_name.to_string()).unwrap(); + + // Initialize networking + println!("Bob: Initializing networking..."); + timeout(Duration::from_secs(10), core.init_networking()) + .await + .unwrap() + .unwrap(); + + tokio::time::sleep(Duration::from_secs(3)).await; + println!("Bob: Networking initialized successfully"); + + // Wait for Alice's pairing code + println!("Bob: Waiting for pairing code from Alice..."); + let mut attempts = 0; + let pairing_code = loop { + if let Ok(code) = + std::fs::read_to_string("/tmp/spacedrive-device-ops-test/pairing_code.txt") + { + break code; + } + tokio::time::sleep(Duration::from_millis(500)).await; + attempts += 1; + if attempts > 40 { + panic!("Bob: Timeout waiting for pairing code"); + } + }; + + println!("Bob: Got pairing code, joining..."); + + // Join pairing + if let Some(networking) = core.networking() { + timeout( + Duration::from_secs(20), + networking.start_pairing_as_joiner(&pairing_code, false), + ) + .await + .unwrap() + .unwrap(); + } else { + panic!("Networking not initialized"); + } + + println!("Bob: Successfully joined pairing"); + + // Wait for pairing completion + println!("Bob: Waiting for pairing to complete..."); + let mut attempts = 0; + let max_attempts = 30; + + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + + let connected_devices = core.services.device.get_connected_devices().await.unwrap(); + if !connected_devices.is_empty() { + println!("Bob: Pairing completed successfully!"); + + let device_info = core + .services + .device + .get_connected_devices_info() + .await + .unwrap(); + + for device in &device_info { + println!( + "Bob paired with: {} (ID: {})", + device.device_name, device.device_id + ); + } + + // Wait for persistent connection + println!("Bob: Waiting for persistent connection..."); + tokio::time::sleep(Duration::from_secs(10)).await; + + // Write success marker + std::fs::write("/tmp/spacedrive-device-ops-test/bob_success.txt", "success").unwrap(); + + // Keep Bob alive while Alice unpairs + // Bob should detect disconnection when Alice unpairs + println!("Bob: Waiting for potential unpair..."); + tokio::time::sleep(Duration::from_secs(30)).await; + + break; + } + + attempts += 1; + if attempts >= max_attempts { + panic!("Bob: Pairing timeout"); + } + } + + println!("Bob: Test completed"); +} + +/// Main test orchestrator - tests device pairing and unpair operations +#[tokio::test] +async fn test_device_operations() { + println!("Testing device pairing and unpair operations"); + + // Clean up from previous runs + let _ = std::fs::remove_dir_all("/tmp/spacedrive-device-ops-test"); + std::fs::create_dir_all("/tmp/spacedrive-device-ops-test").unwrap(); + + let mut runner = CargoTestRunner::for_test_file("device_operation_test") + .with_timeout(Duration::from_secs(180)) + .add_subprocess("alice", "alice_device_ops_scenario") + .add_subprocess("bob", "bob_device_ops_scenario") + .add_subprocess("alice_restart", "alice_device_ops_scenario"); + + // PHASE 1: Pair devices and unpair + println!("\n=== PHASE 1: Pairing and Unpair ===\n"); + + // Spawn Alice first + println!("Starting Alice as initiator..."); + runner + .spawn_single_process("alice") + .await + .expect("Failed to spawn Alice"); + + // Wait for Alice to initialize and generate pairing code + tokio::time::sleep(Duration::from_secs(8)).await; + + // Start Bob as joiner + println!("Starting Bob as joiner..."); + runner + .spawn_single_process("bob") + .await + .expect("Failed to spawn Bob"); + + // Run until both complete pairing and Alice unpairs Bob + let result = runner + .wait_for_success(|_outputs| { + let alice_success = + std::fs::read_to_string("/tmp/spacedrive-device-ops-test/alice_success.txt") + .map(|content| content.trim() == "success") + .unwrap_or(false); + let bob_success = + std::fs::read_to_string("/tmp/spacedrive-device-ops-test/bob_success.txt") + .map(|content| content.trim() == "success") + .unwrap_or(false); + + alice_success && bob_success + }) + .await; + + match result { + Ok(_) => println!("✓ Phase 1 completed: Devices paired and unpaired successfully"), + Err(e) => { + println!("Phase 1 failed: {}", e); + for (name, output) in runner.get_all_outputs() { + println!("\n{} output:\n{}", name, output); + } + panic!("Phase 1 failed: {}", e); + } + } + + // Kill Bob process as it's no longer needed + runner.kill_all().await; + + // Wait a bit for cleanup + tokio::time::sleep(Duration::from_secs(3)).await; + + // PHASE 2: Restart Alice and verify unpaired device stays gone + println!("\n=== PHASE 2: Restart Verification ===\n"); + + println!("Restarting Alice to verify persistence..."); + runner + .spawn_single_process("alice_restart") + .await + .expect("Failed to spawn Alice restart"); + + let result = runner + .wait_for_success(|_outputs| { + std::fs::read_to_string("/tmp/spacedrive-device-ops-test/alice_restart_success.txt") + .map(|content| content.trim() == "success") + .unwrap_or(false) + }) + .await; + + match result { + Ok(_) => println!("✓ Phase 2 completed: Unpaired device stayed removed after restart"), + Err(e) => { + println!("Phase 2 failed: {}", e); + for (name, output) in runner.get_all_outputs() { + println!("\n{} output:\n{}", name, output); + } + panic!("Phase 2 failed: {}", e); + } + } + + // Final cleanup + runner.kill_all().await; + + println!("\n=== ✓ ALL TESTS PASSED ===\n"); + println!("Verified:"); + println!(" • Device pairing works"); + println!(" • Device unpair removes from registry"); + println!(" • Device unpair removes from DeviceManager cache"); + println!(" • Device unpair removes from KeyManager storage"); + println!(" • Unpaired device doesn't reappear after restart"); +} From d52e89768de03c2c5eab4da2fa83a08c69f617ff Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 22 Dec 2025 05:37:08 -0800 Subject: [PATCH 08/12] Refactor Explorer context and navigation handling - Replaced useState with useReducer in the Explorer context for improved state management. - Updated navigation functions to use navigateToPath instead of setCurrentPath, enhancing clarity and consistency. - Removed unused synchronization functions for URL parameters, streamlining the code. - Introduced new utility functions for target management and improved type definitions for navigation targets. - Enhanced the handling of view settings and preferences, ensuring better integration with the UI state. - Deleted obsolete device operation test file to clean up the codebase. --- .../ios/Spacedrive.xcodeproj/project.pbxproj | 222 ++--- .../xcschemes/Spacedrive.xcscheme | 2 +- core/tests/device_operation_test.rs | 523 ------------ .../src/components/Explorer/ExplorerView.tsx | 55 +- .../src/components/Explorer/context.tsx | 798 ++++++++++-------- .../Explorer/hooks/useExplorerKeyboard.ts | 6 +- .../Explorer/hooks/useFileContextMenu.ts | 4 +- .../src/components/Explorer/index.ts | 3 +- .../Explorer/views/ColumnView/ColumnView.tsx | 165 ++-- .../Explorer/views/GridView/FileCard.tsx | 6 +- .../Explorer/views/ListView/TableRow.tsx | 8 +- .../Explorer/views/SizeView/SizeView.tsx | 72 +- .../components/SpacesSidebar/DevicesGroup.tsx | 4 +- .../components/SpacesSidebar/SpaceItem.tsx | 4 +- .../components/SpacesSidebar/TagsGroup.tsx | 8 +- .../hooks/useSpaceItemContextMenu.ts | 4 +- 16 files changed, 710 insertions(+), 1174 deletions(-) delete mode 100644 core/tests/device_operation_test.rs diff --git a/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj b/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj index 02a04ece6..603bc5c61 100644 --- a/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj @@ -9,10 +9,10 @@ /* Begin PBXBuildFile section */ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; - 97715081206FCB268DE6EF1C /* libPods-Spacedrive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 8D2433440EF620DAB0150F1F /* libPods-Spacedrive.a */; }; + 3F6D28D8CA4B3C1FDF9E78A6 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9029D97821ED7825EDEDD6C /* ExpoModulesProvider.swift */; }; + B7DAB1C86A3850F7C3233BE9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AA40588139FF2A1626CB0A54 /* PrivacyInfo.xcprivacy */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; - D7EEB2E25018FB2A40EE29D2 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25178F2D681F4D65F10117CC /* ExpoModulesProvider.swift */; }; - E48312EB51FF1B0800CD3FA4 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 1F01297183477A50BDE411D2 /* PrivacyInfo.xcprivacy */; }; + C046F0D9873F8B0D24767F2C /* libPods-Spacedrive.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 48CBEA58F7B27690483159A9 /* libPods-Spacedrive.a */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; /* End PBXBuildFile section */ @@ -20,16 +20,16 @@ 13B07F961A680F5B00A75B9A /* Spacedrive.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spacedrive.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Spacedrive/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Spacedrive/Info.plist; sourceTree = ""; }; - 1F01297183477A50BDE411D2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Spacedrive/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 22C87D25B25EC9845FB7C7F8 /* Pods-Spacedrive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Spacedrive.debug.xcconfig"; path = "Target Support Files/Pods-Spacedrive/Pods-Spacedrive.debug.xcconfig"; sourceTree = ""; }; - 25178F2D681F4D65F10117CC /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift"; sourceTree = ""; }; - 6BF866963457A8D6655775AD /* Pods-Spacedrive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Spacedrive.release.xcconfig"; path = "Target Support Files/Pods-Spacedrive/Pods-Spacedrive.release.xcconfig"; sourceTree = ""; }; - 8D2433440EF620DAB0150F1F /* libPods-Spacedrive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Spacedrive.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 48CBEA58F7B27690483159A9 /* libPods-Spacedrive.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Spacedrive.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 4D70C582F0FE4A3831CB86B0 /* Pods-Spacedrive.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Spacedrive.debug.xcconfig"; path = "Target Support Files/Pods-Spacedrive/Pods-Spacedrive.debug.xcconfig"; sourceTree = ""; }; + 74DA361BB374DD6494CB5246 /* Pods-Spacedrive.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Spacedrive.release.xcconfig"; path = "Target Support Files/Pods-Spacedrive/Pods-Spacedrive.release.xcconfig"; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Spacedrive/SplashScreen.storyboard; sourceTree = ""; }; + AA40588139FF2A1626CB0A54 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Spacedrive/PrivacyInfo.xcprivacy; sourceTree = ""; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Spacedrive/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* Spacedrive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Spacedrive-Bridging-Header.h"; path = "Spacedrive/Spacedrive-Bridging-Header.h"; sourceTree = ""; }; + F9029D97821ED7825EDEDD6C /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -37,13 +37,21 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 97715081206FCB268DE6EF1C /* libPods-Spacedrive.a in Frameworks */, + C046F0D9873F8B0D24767F2C /* libPods-Spacedrive.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0406F24C3ABEBFD6CF35395F /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + A6A0A3E08EC06A5293CBB19F /* Spacedrive */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; 13B07FAE1A68108700A75B9A /* Spacedrive */ = { isa = PBXGroup; children = ( @@ -53,7 +61,7 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB61A68108700A75B9A /* Info.plist */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, - 1F01297183477A50BDE411D2 /* PrivacyInfo.xcprivacy */, + AA40588139FF2A1626CB0A54 /* PrivacyInfo.xcprivacy */, ); name = Spacedrive; sourceTree = ""; @@ -62,29 +70,11 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - 8D2433440EF620DAB0150F1F /* libPods-Spacedrive.a */, + 48CBEA58F7B27690483159A9 /* libPods-Spacedrive.a */, ); name = Frameworks; sourceTree = ""; }; - 42798537B08B5D76DFB457E7 /* Pods */ = { - isa = PBXGroup; - children = ( - 22C87D25B25EC9845FB7C7F8 /* Pods-Spacedrive.debug.xcconfig */, - 6BF866963457A8D6655775AD /* Pods-Spacedrive.release.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - 4FB1CBF77585F74645A1185E /* Spacedrive */ = { - isa = PBXGroup; - children = ( - 25178F2D681F4D65F10117CC /* ExpoModulesProvider.swift */, - ); - name = Spacedrive; - sourceTree = ""; - }; 832341AE1AAA6A7D00B99B32 /* Libraries */ = { isa = PBXGroup; children = ( @@ -99,8 +89,8 @@ 832341AE1AAA6A7D00B99B32 /* Libraries */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, - B72BBC0A0CC1879D53F63DAB /* ExpoModulesProviders */, - 42798537B08B5D76DFB457E7 /* Pods */, + E0DC8B892BBF51D498C04E89 /* Pods */, + 0406F24C3ABEBFD6CF35395F /* ExpoModulesProviders */, ); indentWidth = 2; sourceTree = ""; @@ -115,12 +105,12 @@ name = Products; sourceTree = ""; }; - B72BBC0A0CC1879D53F63DAB /* ExpoModulesProviders */ = { + A6A0A3E08EC06A5293CBB19F /* Spacedrive */ = { isa = PBXGroup; children = ( - 4FB1CBF77585F74645A1185E /* Spacedrive */, + F9029D97821ED7825EDEDD6C /* ExpoModulesProvider.swift */, ); - name = ExpoModulesProviders; + name = Spacedrive; sourceTree = ""; }; BB2F792B24A3F905000567C9 /* Supporting */ = { @@ -132,6 +122,16 @@ path = Spacedrive/Supporting; sourceTree = ""; }; + E0DC8B892BBF51D498C04E89 /* Pods */ = { + isa = PBXGroup; + children = ( + 4D70C582F0FE4A3831CB86B0 /* Pods-Spacedrive.debug.xcconfig */, + 74DA361BB374DD6494CB5246 /* Pods-Spacedrive.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -139,14 +139,14 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Spacedrive" */; buildPhases = ( - 52BA9D16F880670D9D7E318C /* [CP] Check Pods Manifest.lock */, - AFC9D743E8C8086DE4D89015 /* [Expo] Configure project */, + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + 0B49C67E201184D33FDE32F4 /* [Expo] Configure project */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - 4F55279F996324975C6F08DE /* [CP] Embed Pods Frameworks */, - 31F4BA6009A034F8388CF793 /* [CP] Copy Pods Resources */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + 7B5809D980A4A36345CE0978 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -196,7 +196,7 @@ BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, - E48312EB51FF1B0800CD3FA4 /* PrivacyInfo.xcprivacy in Resources */, + B7DAB1C86A3850F7C3233BE9 /* PrivacyInfo.xcprivacy in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -220,7 +220,75 @@ shellPath = /bin/sh; shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n"; }; - 31F4BA6009A034F8388CF793 /* [CP] Copy Pods Resources */ = { + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Spacedrive-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 0B49C67E201184D33FDE32F4 /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env", + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/Spacedrive/Spacedrive.entitlements", + "$(SRCROOT)/Pods/Target Support Files/Pods-Spacedrive/expo-configure-project.sh", + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Spacedrive/expo-configure-project.sh\"\n"; + }; + 7B5809D980A4A36345CE0978 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -256,74 +324,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 4F55279F996324975C6F08DE /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-frameworks.sh", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - 52BA9D16F880670D9D7E318C /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Spacedrive-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - AFC9D743E8C8086DE4D89015 /* [Expo] Configure project */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "$(SRCROOT)/.xcode.env", - "$(SRCROOT)/.xcode.env.local", - "$(SRCROOT)/Spacedrive/Spacedrive.entitlements", - "$(SRCROOT)/Pods/Target Support Files/Pods-Spacedrive/expo-configure-project.sh", - ); - name = "[Expo] Configure project"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(SRCROOT)/Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Spacedrive/expo-configure-project.sh\"\n"; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -332,7 +332,7 @@ buildActionMask = 2147483647; files = ( F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, - D7EEB2E25018FB2A40EE29D2 /* ExpoModulesProvider.swift in Sources */, + 3F6D28D8CA4B3C1FDF9E78A6 /* ExpoModulesProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -341,7 +341,7 @@ /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 22C87D25B25EC9845FB7C7F8 /* Pods-Spacedrive.debug.xcconfig */; + baseConfigurationReference = 4D70C582F0FE4A3831CB86B0 /* Pods-Spacedrive.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -378,7 +378,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 6BF866963457A8D6655775AD /* Pods-Spacedrive.release.xcconfig */; + baseConfigurationReference = 74DA361BB374DD6494CB5246 /* Pods-Spacedrive.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; diff --git a/apps/mobile/ios/Spacedrive.xcodeproj/xcshareddata/xcschemes/Spacedrive.xcscheme b/apps/mobile/ios/Spacedrive.xcodeproj/xcshareddata/xcschemes/Spacedrive.xcscheme index 254ea947b..b2fefaad6 100644 --- a/apps/mobile/ios/Spacedrive.xcodeproj/xcshareddata/xcschemes/Spacedrive.xcscheme +++ b/apps/mobile/ios/Spacedrive.xcodeproj/xcshareddata/xcschemes/Spacedrive.xcscheme @@ -41,7 +41,7 @@ >() - .join(" "); - println!( - "Alice: Pairing code generated: {}... (expires in {}s)", - short_code, expires_in - ); - - // Write pairing code for Bob - std::fs::create_dir_all("/tmp/spacedrive-device-ops-test").unwrap(); - std::fs::write( - "/tmp/spacedrive-device-ops-test/pairing_code.txt", - &pairing_code, - ) - .unwrap(); - - // Wait for pairing completion - println!("Alice: Waiting for pairing to complete..."); - let mut bob_device_id = None; - let mut attempts = 0; - let max_attempts = 45; - - loop { - tokio::time::sleep(Duration::from_secs(1)).await; - - let connected_devices = core.services.device.get_connected_devices().await.unwrap(); - if !connected_devices.is_empty() { - println!("Alice: Pairing completed successfully!"); - - let device_info = core - .services - .device - .get_connected_devices_info() - .await - .unwrap(); - - for device in &device_info { - println!( - "Alice paired with: {} (ID: {})", - device.device_name, device.device_id - ); - if device.device_name.contains("Bob") { - bob_device_id = Some(device.device_id); - } - } - - assert!( - bob_device_id.is_some(), - "Bob's device not found in paired devices" - ); - break; - } - - attempts += 1; - if attempts >= max_attempts { - panic!("Alice: Pairing timeout"); - } - } - - let bob_id = bob_device_id.unwrap(); - println!("Alice: Bob's device ID: {}", bob_id); - - // Give Bob time to also detect the connection - tokio::time::sleep(Duration::from_secs(3)).await; - - // Now UNPAIR Bob - println!("Alice: Unpairing Bob's device..."); - - if let Some(networking) = core.networking() { - // Verify Bob is in paired devices before unpair - { - let registry = networking.device_registry(); - let guard = registry.read().await; - let paired_before = guard.get_paired_devices(); - println!( - "Alice: Paired devices before unpair: {}", - paired_before.len() - ); - assert_eq!( - paired_before.len(), - 1, - "Should have exactly 1 paired device" - ); - } - - // Execute unpair by calling registry methods directly (same as DeviceRevokeAction) - let registry = networking.device_registry(); - let result = { - let mut guard = registry.write().await; - guard.remove_device(bob_id).unwrap(); - guard.remove_paired_device(bob_id).await.unwrap() - }; - - println!("Alice: Unpair result: removed={}", result); - assert!(result, "Unpair operation failed - device not found"); - - // Remove from DeviceManager cache (same as action does) - if let Err(e) = core.device.remove_paired_device_from_cache(bob_id) { - println!("Alice: Warning - failed to remove from cache: {}", e); - } - - // Give time for cleanup to complete - tokio::time::sleep(Duration::from_secs(2)).await; - - // Verify Bob is removed from paired devices - { - let registry = networking.device_registry(); - let guard = registry.read().await; - let paired_after = guard.get_paired_devices(); - println!("Alice: Paired devices after unpair: {}", paired_after.len()); - assert_eq!( - paired_after.len(), - 0, - "Device still in paired list after unpair!" - ); - } - - println!("Alice: ✓ Verified device removed from registry"); - - // Verify Bob is removed from DeviceManager cache - let device_by_slug = core.device.resolve_by_slug("bobs-test-device"); - assert!( - device_by_slug.is_none(), - "Device still in DeviceManager cache after unpair!" - ); - println!("Alice: ✓ Verified device removed from DeviceManager cache"); - - // Verify Bob disconnected - let connected_after = core.services.device.get_connected_devices().await.unwrap(); - assert_eq!( - connected_after.len(), - 0, - "Device still connected after unpair!" - ); - println!("Alice: ✓ Verified device disconnected"); - } - - // Write success marker - std::fs::write( - "/tmp/spacedrive-device-ops-test/alice_success.txt", - "success", - ) - .unwrap(); - - println!("Alice: Initial phase completed successfully"); -} - -/// Bob's device operation scenario - pairs with Alice, gets unpaired -#[tokio::test] -#[ignore] // Only run when explicitly called via subprocess -async fn bob_device_ops_scenario() { - if env::var("TEST_ROLE").unwrap_or_default() != "bob" { - return; - } - - let data_dir = PathBuf::from("/tmp/spacedrive-device-ops-test/bob"); - let device_name = "Bob's Test Device"; - - // Set test directory for file-based discovery - env::set_var("SPACEDRIVE_TEST_DIR", "/tmp/spacedrive-device-ops-test"); - - println!("Bob: Starting pairing scenario"); - println!("Bob: Data dir: {:?}", data_dir); - - // Initialize Core - println!("Bob: Initializing Core..."); - let mut core = timeout(Duration::from_secs(10), Core::new(data_dir)) - .await - .unwrap() - .unwrap(); - println!("Bob: Core initialized successfully"); - - // Set device name - core.device.set_name(device_name.to_string()).unwrap(); - - // Initialize networking - println!("Bob: Initializing networking..."); - timeout(Duration::from_secs(10), core.init_networking()) - .await - .unwrap() - .unwrap(); - - tokio::time::sleep(Duration::from_secs(3)).await; - println!("Bob: Networking initialized successfully"); - - // Wait for Alice's pairing code - println!("Bob: Waiting for pairing code from Alice..."); - let mut attempts = 0; - let pairing_code = loop { - if let Ok(code) = - std::fs::read_to_string("/tmp/spacedrive-device-ops-test/pairing_code.txt") - { - break code; - } - tokio::time::sleep(Duration::from_millis(500)).await; - attempts += 1; - if attempts > 40 { - panic!("Bob: Timeout waiting for pairing code"); - } - }; - - println!("Bob: Got pairing code, joining..."); - - // Join pairing - if let Some(networking) = core.networking() { - timeout( - Duration::from_secs(20), - networking.start_pairing_as_joiner(&pairing_code, false), - ) - .await - .unwrap() - .unwrap(); - } else { - panic!("Networking not initialized"); - } - - println!("Bob: Successfully joined pairing"); - - // Wait for pairing completion - println!("Bob: Waiting for pairing to complete..."); - let mut attempts = 0; - let max_attempts = 30; - - loop { - tokio::time::sleep(Duration::from_secs(1)).await; - - let connected_devices = core.services.device.get_connected_devices().await.unwrap(); - if !connected_devices.is_empty() { - println!("Bob: Pairing completed successfully!"); - - let device_info = core - .services - .device - .get_connected_devices_info() - .await - .unwrap(); - - for device in &device_info { - println!( - "Bob paired with: {} (ID: {})", - device.device_name, device.device_id - ); - } - - // Wait for persistent connection - println!("Bob: Waiting for persistent connection..."); - tokio::time::sleep(Duration::from_secs(10)).await; - - // Write success marker - std::fs::write("/tmp/spacedrive-device-ops-test/bob_success.txt", "success").unwrap(); - - // Keep Bob alive while Alice unpairs - // Bob should detect disconnection when Alice unpairs - println!("Bob: Waiting for potential unpair..."); - tokio::time::sleep(Duration::from_secs(30)).await; - - break; - } - - attempts += 1; - if attempts >= max_attempts { - panic!("Bob: Pairing timeout"); - } - } - - println!("Bob: Test completed"); -} - -/// Main test orchestrator - tests device pairing and unpair operations -#[tokio::test] -async fn test_device_operations() { - println!("Testing device pairing and unpair operations"); - - // Clean up from previous runs - let _ = std::fs::remove_dir_all("/tmp/spacedrive-device-ops-test"); - std::fs::create_dir_all("/tmp/spacedrive-device-ops-test").unwrap(); - - let mut runner = CargoTestRunner::for_test_file("device_operation_test") - .with_timeout(Duration::from_secs(180)) - .add_subprocess("alice", "alice_device_ops_scenario") - .add_subprocess("bob", "bob_device_ops_scenario") - .add_subprocess("alice_restart", "alice_device_ops_scenario"); - - // PHASE 1: Pair devices and unpair - println!("\n=== PHASE 1: Pairing and Unpair ===\n"); - - // Spawn Alice first - println!("Starting Alice as initiator..."); - runner - .spawn_single_process("alice") - .await - .expect("Failed to spawn Alice"); - - // Wait for Alice to initialize and generate pairing code - tokio::time::sleep(Duration::from_secs(8)).await; - - // Start Bob as joiner - println!("Starting Bob as joiner..."); - runner - .spawn_single_process("bob") - .await - .expect("Failed to spawn Bob"); - - // Run until both complete pairing and Alice unpairs Bob - let result = runner - .wait_for_success(|_outputs| { - let alice_success = - std::fs::read_to_string("/tmp/spacedrive-device-ops-test/alice_success.txt") - .map(|content| content.trim() == "success") - .unwrap_or(false); - let bob_success = - std::fs::read_to_string("/tmp/spacedrive-device-ops-test/bob_success.txt") - .map(|content| content.trim() == "success") - .unwrap_or(false); - - alice_success && bob_success - }) - .await; - - match result { - Ok(_) => println!("✓ Phase 1 completed: Devices paired and unpaired successfully"), - Err(e) => { - println!("Phase 1 failed: {}", e); - for (name, output) in runner.get_all_outputs() { - println!("\n{} output:\n{}", name, output); - } - panic!("Phase 1 failed: {}", e); - } - } - - // Kill Bob process as it's no longer needed - runner.kill_all().await; - - // Wait a bit for cleanup - tokio::time::sleep(Duration::from_secs(3)).await; - - // PHASE 2: Restart Alice and verify unpaired device stays gone - println!("\n=== PHASE 2: Restart Verification ===\n"); - - println!("Restarting Alice to verify persistence..."); - runner - .spawn_single_process("alice_restart") - .await - .expect("Failed to spawn Alice restart"); - - let result = runner - .wait_for_success(|_outputs| { - std::fs::read_to_string("/tmp/spacedrive-device-ops-test/alice_restart_success.txt") - .map(|content| content.trim() == "success") - .unwrap_or(false) - }) - .await; - - match result { - Ok(_) => println!("✓ Phase 2 completed: Unpaired device stayed removed after restart"), - Err(e) => { - println!("Phase 2 failed: {}", e); - for (name, output) in runner.get_all_outputs() { - println!("\n{} output:\n{}", name, output); - } - panic!("Phase 2 failed: {}", e); - } - } - - // Final cleanup - runner.kill_all().await; - - println!("\n=== ✓ ALL TESTS PASSED ===\n"); - println!("Verified:"); - println!(" • Device pairing works"); - println!(" • Device unpair removes from registry"); - println!(" • Device unpair removes from DeviceManager cache"); - println!(" • Device unpair removes from KeyManager storage"); - println!(" • Unpaired device doesn't reappear after restart"); -} diff --git a/packages/interface/src/components/Explorer/ExplorerView.tsx b/packages/interface/src/components/Explorer/ExplorerView.tsx index e8a553cc4..f00e3e3d5 100644 --- a/packages/interface/src/components/Explorer/ExplorerView.tsx +++ b/packages/interface/src/components/Explorer/ExplorerView.tsx @@ -1,5 +1,3 @@ -import { useEffect } from "react"; -import { useSearchParams } from "react-router-dom"; import { useExplorer } from "./context"; import { GridView } from "./views/GridView"; import { ListView } from "./views/ListView"; @@ -25,7 +23,6 @@ import { SortMenu } from "./SortMenu"; import { ViewModeMenu } from "./ViewModeMenu"; export function ExplorerView() { - const [searchParams] = useSearchParams(); const { sidebarVisible, setSidebarVisible, @@ -43,9 +40,7 @@ export function ExplorerView() { canGoForward, currentPath, currentView, - setCurrentPath, - syncPathFromUrl, - syncViewFromUrl, + navigateToPath, devices, quickPreviewFileId, } = useExplorer(); @@ -53,52 +48,6 @@ export function ExplorerView() { const { isVirtualView } = useVirtualListing(); const isPreviewActive = !!quickPreviewFileId; - // Sync currentPath or currentView from URL query parameters - useEffect(() => { - const pathParam = searchParams.get("path"); - const viewParam = searchParams.get("view"); - - if (pathParam) { - try { - const sdPath = JSON.parse(decodeURIComponent(pathParam)); - const currentPathStr = JSON.stringify(currentPath); - const newPathStr = JSON.stringify(sdPath); - - if (currentPathStr !== newPathStr) { - syncPathFromUrl(sdPath); - } - } catch (e) { - console.error("Failed to parse path query parameter:", e); - } - } else if (viewParam) { - const id = searchParams.get("id"); - const params: Record = {}; - searchParams.forEach((value, key) => { - if (key !== "view" && key !== "id") { - params[key] = value; - } - }); - - const newView = { - view: viewParam, - id: id || undefined, - params: Object.keys(params).length > 0 ? params : undefined, - }; - const currentViewStr = JSON.stringify(currentView); - const newViewStr = JSON.stringify(newView); - - if (currentViewStr !== newViewStr) { - syncViewFromUrl(newView); - } - } - }, [ - searchParams, - currentPath, - currentView, - syncPathFromUrl, - syncViewFromUrl, - ]); - // Allow rendering if either we have a currentPath or we're in a virtual view if (!currentPath && !isVirtualView) { return ; @@ -133,7 +82,7 @@ export function ExplorerView() { )} {currentView && ( diff --git a/packages/interface/src/components/Explorer/context.tsx b/packages/interface/src/components/Explorer/context.tsx index b44f72ef5..3137629a1 100644 --- a/packages/interface/src/components/Explorer/context.tsx +++ b/packages/interface/src/components/Explorer/context.tsx @@ -1,20 +1,19 @@ import { createContext, useContext, - useState, + useReducer, useMemo, useEffect, useCallback, - useRef, type ReactNode, } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import { useNormalizedQuery } from "../../context"; -import { usePlatform } from "../../platform"; import type { SdPath, File, + Device, ListLibraryDevicesInput, DirectorySortBy, MediaSortBy, @@ -24,15 +23,24 @@ import { useSortPreferencesStore, } from "@sd/ts-client"; -interface ViewSettings { - gridSize: number; // 80-400px - gapSize: number; // 1-32px +export type SortBy = DirectorySortBy | MediaSortBy; +export type ViewMode = + | "grid" + | "list" + | "media" + | "column" + | "size" + | "knowledge"; + +export interface ViewSettings { + gridSize: number; + gapSize: number; showFileSize: boolean; - columnWidth: number; // 200-400px for column view + columnWidth: number; foldersFirst: boolean; } -export type NavigationEntry = +export type NavigationTarget = | { type: "path"; path: SdPath } | { type: "view"; @@ -41,59 +49,270 @@ export type NavigationEntry = params?: Record; }; -export interface VirtualView { - view: string; - id?: string; - params?: Record; +function targetToKey(target: NavigationTarget): string { + if (target.type === "path") { + const p = target.path; + if ("Physical" in p && p.Physical) { + return `path:${p.Physical.device_slug}:${p.Physical.path}`; + } + if ("Virtual" in p && p.Virtual) { + return `path:virtual:${p.Virtual}`; + } + return `path:${JSON.stringify(p)}`; + } + return `view:${target.view}:${target.id || ""}`; } -function getSpaceItemKeyFromRoute(pathname: string, search: string): string { +function targetsEqual( + a: NavigationTarget | null, + b: NavigationTarget | null, +): boolean { + if (a === null || b === null) return a === b; + return targetToKey(a) === targetToKey(b); +} + +const MAX_HISTORY_SIZE = 100; + +interface NavigationState { + history: NavigationTarget[]; + index: number; +} + +type NavigationAction = + | { type: "NAVIGATE"; target: NavigationTarget } + | { type: "GO_BACK" } + | { type: "GO_FORWARD" } + | { type: "SYNC"; target: NavigationTarget }; + +function navigationReducer( + state: NavigationState, + action: NavigationAction, +): NavigationState { + switch (action.type) { + case "NAVIGATE": { + const current = state.history[state.index]; + if (current && targetsEqual(current, action.target)) { + return state; + } + + const newHistory = state.history.slice(0, state.index + 1); + newHistory.push(action.target); + + const trimmedHistory = newHistory.slice(-MAX_HISTORY_SIZE); + const indexAdjustment = newHistory.length - trimmedHistory.length; + + return { + history: trimmedHistory, + index: state.index + 1 - indexAdjustment, + }; + } + + case "GO_BACK": { + if (state.index <= 0) return state; + return { ...state, index: state.index - 1 }; + } + + case "GO_FORWARD": { + if (state.index >= state.history.length - 1) return state; + return { ...state, index: state.index + 1 }; + } + + case "SYNC": { + const current = state.history[state.index]; + if (current && targetsEqual(current, action.target)) { + return state; + } + + const newHistory = [ + ...state.history.slice(0, state.index + 1), + action.target, + ]; + const trimmedHistory = newHistory.slice(-MAX_HISTORY_SIZE); + const indexAdjustment = newHistory.length - trimmedHistory.length; + + return { + history: trimmedHistory, + index: state.index + 1 - indexAdjustment, + }; + } + + default: + return state; + } +} + +const initialNavigationState: NavigationState = { + history: [], + index: -1, +}; + +interface UIState { + viewMode: ViewMode; + sortBy: SortBy; + viewSettings: ViewSettings; + sidebarVisible: boolean; + inspectorVisible: boolean; + quickPreviewFileId: string | null; + tagModeActive: boolean; +} + +type UIAction = + | { type: "SET_VIEW_MODE"; mode: ViewMode } + | { type: "SET_SORT_BY"; sort: SortBy } + | { type: "SET_VIEW_SETTINGS"; settings: Partial } + | { type: "SET_SIDEBAR_VISIBLE"; visible: boolean } + | { type: "SET_INSPECTOR_VISIBLE"; visible: boolean } + | { type: "SET_QUICK_PREVIEW"; fileId: string | null } + | { type: "SET_TAG_MODE"; active: boolean } + | { + type: "LOAD_PREFERENCES"; + viewMode: ViewMode; + viewSettings?: Partial; + }; + +const defaultViewSettings: ViewSettings = { + gridSize: 120, + gapSize: 16, + showFileSize: true, + columnWidth: 256, + foldersFirst: false, +}; + +function uiReducer(state: UIState, action: UIAction): UIState { + switch (action.type) { + case "SET_VIEW_MODE": + return { ...state, viewMode: action.mode }; + + case "SET_SORT_BY": + return { ...state, sortBy: action.sort }; + + case "SET_VIEW_SETTINGS": + return { + ...state, + viewSettings: { ...state.viewSettings, ...action.settings }, + }; + + case "SET_SIDEBAR_VISIBLE": + return { ...state, sidebarVisible: action.visible }; + + case "SET_INSPECTOR_VISIBLE": + return { ...state, inspectorVisible: action.visible }; + + case "SET_QUICK_PREVIEW": + return { ...state, quickPreviewFileId: action.fileId }; + + case "SET_TAG_MODE": + return { ...state, tagModeActive: action.active }; + + case "LOAD_PREFERENCES": + return { + ...state, + viewMode: action.viewMode, + viewSettings: action.viewSettings + ? { ...state.viewSettings, ...action.viewSettings } + : state.viewSettings, + }; + + default: + return state; + } +} + +const initialUIState: UIState = { + viewMode: "grid", + sortBy: "name", + viewSettings: defaultViewSettings, + sidebarVisible: true, + inspectorVisible: true, + quickPreviewFileId: null, + tagModeActive: false, +}; + +function targetToUrl(target: NavigationTarget): string { + if (target.type === "path") { + const encoded = encodeURIComponent(JSON.stringify(target.path)); + return `/explorer?path=${encoded}`; + } + + const params = new URLSearchParams({ view: target.view }); + if (target.id) params.set("id", target.id); + if (target.params) { + Object.entries(target.params).forEach(([k, v]) => params.set(k, v)); + } + return `/explorer?${params.toString()}`; +} + +function urlToTarget(search: string): NavigationTarget | null { + const params = new URLSearchParams(search); + + const pathParam = params.get("path"); + if (pathParam) { + try { + const path = JSON.parse(decodeURIComponent(pathParam)) as SdPath; + return { type: "path", path }; + } catch { + return null; + } + } + + const view = params.get("view"); + if (view) { + const id = params.get("id") || undefined; + const extraParams: Record = {}; + params.forEach((v, k) => { + if (k !== "view" && k !== "id") extraParams[k] = v; + }); + return { + type: "view", + view, + id, + params: + Object.keys(extraParams).length > 0 ? extraParams : undefined, + }; + } + + return null; +} + +function getSpaceItemKey(pathname: string, search: string): string { if (pathname === "/") return "overview"; if (pathname === "/recents") return "recents"; if (pathname === "/favorites") return "favorites"; if (pathname === "/file-kinds") return "file-kinds"; - if (pathname.startsWith("/tag/")) { - const tagId = pathname.replace("/tag/", ""); - return `tag:${tagId}`; - } - if (pathname === "/explorer" && search) { - return `explorer:${search}`; - } + if (pathname.startsWith("/tag/")) return `tag:${pathname.slice(5)}`; + if (pathname === "/explorer" && search) return `explorer:${search}`; return pathname; } -function getPathKey(sdPath: SdPath | null): string { - if (!sdPath) return "null"; - return JSON.stringify(sdPath); +function getPathKey(target: NavigationTarget | null): string { + if (!target) return "null"; + return targetToKey(target); } -interface ExplorerState { +interface ExplorerContextValue { + currentTarget: NavigationTarget | null; currentPath: SdPath | null; - currentView: VirtualView | null; - setCurrentPath: (path: SdPath | null) => void; + currentView: { + view: string; + id?: string; + params?: Record; + } | null; + + navigateToPath: (path: SdPath) => void; navigateToView: ( view: string, id?: string, params?: Record, ) => void; - syncPathFromUrl: (path: SdPath | null) => void; - syncViewFromUrl: (view: VirtualView | null) => void; - - history: NavigationEntry[]; - historyIndex: number; goBack: () => void; goForward: () => void; canGoBack: boolean; canGoForward: boolean; - viewMode: "grid" | "list" | "media" | "column" | "size" | "knowledge"; - setViewMode: ( - mode: "grid" | "list" | "media" | "column" | "size" | "knowledge", - ) => void; - - sortBy: DirectorySortBy | MediaSortBy; - setSortBy: (sort: DirectorySortBy | MediaSortBy) => void; - + viewMode: ViewMode; + setViewMode: (mode: ViewMode) => void; + sortBy: SortBy; + setSortBy: (sort: SortBy) => void; viewSettings: ViewSettings; setViewSettings: (settings: Partial) => void; @@ -103,7 +322,6 @@ interface ExplorerState { setInspectorVisible: (visible: boolean) => void; quickPreviewFileId: string | null; - setQuickPreviewFileId: (fileId: string | null) => void; openQuickPreview: (fileId: string) => void; closeQuickPreview: () => void; @@ -113,375 +331,253 @@ interface ExplorerState { tagModeActive: boolean; setTagModeActive: (active: boolean) => void; - devices: Map; + devices: Map; - setSpaceItemId: (id: string) => void; - - // Set space item ID and trigger preference loading (use when navigating from sidebar) - setSpaceItemIdFromSidebar: (id: string) => void; + loadPreferencesForSpaceItem: (id: string) => void; } -const ExplorerContext = createContext(null); +const ExplorerContext = createContext(null); interface ExplorerProviderProps { children: ReactNode; - spaceItemId?: string; } -export function ExplorerProvider({ - children, - spaceItemId: initialSpaceItemId, -}: ExplorerProviderProps) { - const navigate = useNavigate(); - const platform = usePlatform(); +export function ExplorerProvider({ children }: ExplorerProviderProps) { + const routerNavigate = useNavigate(); + const location = useLocation(); const viewPrefs = useViewPreferencesStore(); const sortPrefs = useSortPreferencesStore(); - const [spaceItemIdInternal, setSpaceItemIdInternal] = useState( - initialSpaceItemId || "default", + const [navState, navDispatch] = useReducer( + navigationReducer, + initialNavigationState, ); - // Track if the next spaceItemId change should load preferences - const shouldLoadPreferencesRef = useRef(false); - const [currentPath, setCurrentPathInternal] = useState(null); - const [currentView, setCurrentView] = useState(null); - const [history, setHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); - const [viewMode, setViewModeInternal] = useState< - "grid" | "list" | "media" | "column" | "size" | "knowledge" - >("grid"); - const [sortByInternal, setSortByInternal] = useState< - DirectorySortBy | MediaSortBy - >("name"); - const [viewSettings, setViewSettingsInternal] = useState({ - gridSize: 120, - gapSize: 16, - showFileSize: true, - columnWidth: 256, - foldersFirst: false, - }); - const [sidebarVisible, setSidebarVisible] = useState(true); - const [inspectorVisible, setInspectorVisible] = useState(true); - const [quickPreviewFileId, setQuickPreviewFileId] = useState( - null, + const [uiState, uiDispatch] = useReducer(uiReducer, initialUIState); + const [currentFiles, setCurrentFiles] = useReducer( + (_: File[], files: File[]) => files, + [] as File[], ); - const [currentFiles, setCurrentFiles] = useState([]); - const [tagModeActive, setTagModeActive] = useState(false); - const spaceItemKey = spaceItemIdInternal; - const pathKey = getPathKey(currentPath); + const currentTarget = navState.history[navState.index] ?? null; + const canGoBack = navState.index > 0; + const canGoForward = navState.index < navState.history.length - 1; - // Load view preferences only when navigation originates from sidebar - useEffect(() => { - // Only load preferences when explicitly requested (sidebar navigation) - if (!shouldLoadPreferencesRef.current) { - return; + const currentPath = useMemo(() => { + if (currentTarget?.type === "path") return currentTarget.path; + return null; + }, [currentTarget]); + + const currentView = useMemo(() => { + if (currentTarget?.type === "view") { + return { + view: currentTarget.view, + id: currentTarget.id, + params: currentTarget.params, + }; } - shouldLoadPreferencesRef.current = false; - - const prefs = viewPrefs.getPreferences(spaceItemKey); - if (prefs) { - setViewModeInternal(prefs.viewMode); - if (prefs.viewSettings) { - setViewSettingsInternal((prev) => ({ - ...prev, - ...prefs.viewSettings, - })); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [spaceItemKey]); + return null; + }, [currentTarget]); - // Load sort preferences when path changes - useEffect(() => { - const sortPref = sortPrefs.getPreferences(pathKey); - if (sortPref) { - setSortByInternal(sortPref as DirectorySortBy | MediaSortBy); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pathKey]); - - // Wrapper for setViewMode that persists to store - const setViewMode = useCallback( - (mode: "grid" | "list" | "media" | "column" | "size" | "knowledge") => { - setViewModeInternal(mode); - viewPrefs.setPreferences(spaceItemKey, { viewMode: mode }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, - [spaceItemKey], - ); - - // Wrapper for setSortBy that persists to store - const setSortBy = useCallback( - (sort: DirectorySortBy | MediaSortBy) => { - setSortByInternal(sort); - sortPrefs.setPreferences(pathKey, sort); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, - [pathKey], - ); - - // Update sort when switching to media view - useEffect(() => { - if (viewMode === "media" && sortByInternal === "type") { - setSortByInternal("datetaken"); - sortPrefs.setPreferences(pathKey, "datetaken"); - } else if (viewMode !== "media" && sortByInternal === "datetaken") { - setSortByInternal("modified"); - sortPrefs.setPreferences(pathKey, "modified"); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewMode, sortByInternal, pathKey]); - - const setViewSettings = useCallback( - (settings: Partial) => { - setViewSettingsInternal((prev) => { - const updated = { ...prev, ...settings }; - viewPrefs.setPreferences(spaceItemKey, { - viewSettings: updated, - }); - return updated; - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, - [spaceItemKey], - ); - - // Set space item ID from sidebar navigation (triggers preference loading) - const setSpaceItemIdFromSidebar = useCallback((id: string) => { - shouldLoadPreferencesRef.current = true; - setSpaceItemIdInternal(id); - }, []); - - // Use normalized query for automatic updates when device events are emitted - const devicesQuery = useNormalizedQuery({ + const devicesQuery = useNormalizedQuery({ wireMethod: "query:devices.list", input: { include_offline: true, include_details: false }, resourceType: "device", }); const devices = useMemo(() => { - const deviceList = devicesQuery.data || []; - return new Map(deviceList.map((d) => [d.id, d])); + const list = devicesQuery.data ?? []; + return new Map(list.map((d) => [d.id, d])); }, [devicesQuery.data]); - const goBack = useCallback(() => { - console.log("[Explorer] goBack called:", { - historyIndex, - historyLength: history.length, - canGoBack: historyIndex > 0, - }); - - if (historyIndex > 0) { - const newIndex = historyIndex - 1; - const entry = history[newIndex]; - - console.log("[Explorer] Going back to:", { newIndex, entry }); - - setHistoryIndex(newIndex); - - if (entry.type === "path") { - setCurrentPathInternal(entry.path); - setCurrentView(null); - const encodedPath = encodeURIComponent( - JSON.stringify(entry.path), - ); - navigate(`/explorer?path=${encodedPath}`, { replace: true }); - } else { - setCurrentPathInternal(null); - setCurrentView({ - view: entry.view, - id: entry.id, - params: entry.params, - }); - const params = new URLSearchParams({ - view: entry.view, - ...(entry.id && { id: entry.id }), - ...(entry.params || {}), - }); - navigate(`/explorer?${params.toString()}`, { replace: true }); - } + // Exclude currentTarget from deps to prevent infinite sync loops. + useEffect(() => { + const target = urlToTarget(location.search); + if (target && !targetsEqual(target, currentTarget)) { + navDispatch({ type: "SYNC", target }); } - }, [historyIndex, history, navigate]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [location.search]); - const goForward = useCallback(() => { - console.log("[Explorer] goForward called:", { - historyIndex, - historyLength: history.length, - canGoForward: historyIndex < history.length - 1, - }); + const pathKey = getPathKey(currentTarget); - if (historyIndex < history.length - 1) { - const newIndex = historyIndex + 1; - const entry = history[newIndex]; - - console.log("[Explorer] Going forward to:", { newIndex, entry }); - - setHistoryIndex(newIndex); - - if (entry.type === "path") { - setCurrentPathInternal(entry.path); - setCurrentView(null); - const encodedPath = encodeURIComponent( - JSON.stringify(entry.path), - ); - navigate(`/explorer?path=${encodedPath}`, { replace: true }); - } else { - setCurrentPathInternal(null); - setCurrentView({ - view: entry.view, - id: entry.id, - params: entry.params, - }); - const params = new URLSearchParams({ - view: entry.view, - ...(entry.id && { id: entry.id }), - ...(entry.params || {}), - }); - navigate(`/explorer?${params.toString()}`, { replace: true }); - } + useEffect(() => { + const savedSort = sortPrefs.getPreferences(pathKey); + if (savedSort) { + uiDispatch({ type: "SET_SORT_BY", sort: savedSort as SortBy }); } - }, [historyIndex, history, navigate]); + }, [pathKey, sortPrefs]); - const canGoBack = historyIndex > 0; - const canGoForward = historyIndex < history.length - 1; + // "datetaken" only applies to media view; fall back to "modified" elsewhere. + useEffect(() => { + if (uiState.viewMode === "media" && uiState.sortBy === "type") { + uiDispatch({ type: "SET_SORT_BY", sort: "datetaken" }); + sortPrefs.setPreferences(pathKey, "datetaken"); + } else if ( + uiState.viewMode !== "media" && + uiState.sortBy === "datetaken" + ) { + uiDispatch({ type: "SET_SORT_BY", sort: "modified" }); + sortPrefs.setPreferences(pathKey, "modified"); + } + }, [uiState.viewMode, uiState.sortBy, pathKey, sortPrefs]); const navigateToPath = useCallback( - (path: SdPath | null) => { - if (!path) { - setCurrentPathInternal(null); - return; - } - - // Clear view state - setCurrentView(null); - - // Update history - setHistory((prev) => { - const newHistory = prev.slice(0, historyIndex + 1); - newHistory.push({ type: "path", path }); - return newHistory; - }); - setHistoryIndex((prev) => prev + 1); - setCurrentPathInternal(path); - - // Update URL to match - const encodedPath = encodeURIComponent(JSON.stringify(path)); - navigate(`/explorer?path=${encodedPath}`, { replace: false }); + (path: SdPath) => { + const target: NavigationTarget = { type: "path", path }; + navDispatch({ type: "NAVIGATE", target }); + routerNavigate(targetToUrl(target)); }, - [historyIndex, navigate], + [routerNavigate], ); const navigateToView = useCallback( (view: string, id?: string, params?: Record) => { - // Clear path state - setCurrentPathInternal(null); - - // Set view state - setCurrentView({ view, id, params }); - - // Update history - setHistory((prev) => { - const newHistory = prev.slice(0, historyIndex + 1); - newHistory.push({ type: "view", view, id, params }); - return newHistory; - }); - setHistoryIndex((prev) => prev + 1); - - // Update URL - const queryParams = new URLSearchParams({ - view, - ...(id && { id }), - ...(params || {}), - }); - navigate(`/explorer?${queryParams.toString()}`, { replace: false }); + const target: NavigationTarget = { type: "view", view, id, params }; + navDispatch({ type: "NAVIGATE", target }); + routerNavigate(targetToUrl(target)); }, - [historyIndex, navigate], + [routerNavigate], ); - const syncPathFromUrl = useCallback((path: SdPath | null) => { - // Update internal state without navigating - used when URL changes externally - setCurrentPathInternal(path); - setCurrentView(null); // Clear view when syncing path + const goBack = useCallback(() => { + navDispatch({ type: "GO_BACK" }); + const targetIndex = navState.index - 1; + if (targetIndex >= 0) { + const target = navState.history[targetIndex]; + routerNavigate(targetToUrl(target), { replace: true }); + } + }, [navState.index, navState.history, routerNavigate]); + + const goForward = useCallback(() => { + navDispatch({ type: "GO_FORWARD" }); + const targetIndex = navState.index + 1; + if (targetIndex < navState.history.length) { + const target = navState.history[targetIndex]; + routerNavigate(targetToUrl(target), { replace: true }); + } + }, [navState.index, navState.history, routerNavigate]); + + const spaceKey = getSpaceItemKey(location.pathname, location.search); + + const setViewMode = useCallback( + (mode: ViewMode) => { + uiDispatch({ type: "SET_VIEW_MODE", mode }); + viewPrefs.setPreferences(spaceKey, { viewMode: mode }); + }, + [spaceKey, viewPrefs], + ); + + const setSortBy = useCallback( + (sort: SortBy) => { + uiDispatch({ type: "SET_SORT_BY", sort }); + sortPrefs.setPreferences(pathKey, sort); + }, + [pathKey, sortPrefs], + ); + + const setViewSettings = useCallback( + (settings: Partial) => { + uiDispatch({ type: "SET_VIEW_SETTINGS", settings }); + viewPrefs.setPreferences(spaceKey, { + viewSettings: { ...uiState.viewSettings, ...settings }, + }); + }, + [spaceKey, uiState.viewSettings, viewPrefs], + ); + + const setSidebarVisible = useCallback((visible: boolean) => { + uiDispatch({ type: "SET_SIDEBAR_VISIBLE", visible }); }, []); - const syncViewFromUrl = useCallback((view: VirtualView | null) => { - // Update internal state without navigating - used when URL changes externally - setCurrentView(view); - setCurrentPathInternal(null); // Clear path when syncing view + const setInspectorVisible = useCallback((visible: boolean) => { + uiDispatch({ type: "SET_INSPECTOR_VISIBLE", visible }); }, []); const openQuickPreview = useCallback((fileId: string) => { - setQuickPreviewFileId(fileId); + uiDispatch({ type: "SET_QUICK_PREVIEW", fileId }); }, []); const closeQuickPreview = useCallback(() => { - setQuickPreviewFileId(null); + uiDispatch({ type: "SET_QUICK_PREVIEW", fileId: null }); }, []); - const value: ExplorerState = useMemo( + const setTagModeActive = useCallback((active: boolean) => { + uiDispatch({ type: "SET_TAG_MODE", active }); + }, []); + + const loadPreferencesForSpaceItem = useCallback( + (id: string) => { + const prefs = viewPrefs.getPreferences(id); + if (prefs) { + uiDispatch({ + type: "LOAD_PREFERENCES", + viewMode: prefs.viewMode, + viewSettings: prefs.viewSettings, + }); + } + }, + [viewPrefs], + ); + + const value = useMemo( () => ({ - currentPath, - currentView, - setCurrentPath: navigateToPath, - navigateToView, - syncPathFromUrl, - syncViewFromUrl, - history, - historyIndex, - goBack, - goForward, - canGoBack, - canGoForward, - viewMode, - setViewMode, - sortBy: sortByInternal, - setSortBy, - viewSettings, - setViewSettings, - sidebarVisible, - setSidebarVisible, - inspectorVisible, - setInspectorVisible, - quickPreviewFileId, - setQuickPreviewFileId, - openQuickPreview, - closeQuickPreview, - currentFiles, - setCurrentFiles, - tagModeActive, - setTagModeActive, - devices, - setSpaceItemId: setSpaceItemIdInternal, - setSpaceItemIdFromSidebar, - }), - [ + currentTarget, currentPath, currentView, navigateToPath, navigateToView, - syncPathFromUrl, - syncViewFromUrl, - history, - historyIndex, goBack, goForward, canGoBack, canGoForward, - viewMode, + viewMode: uiState.viewMode, setViewMode, - sortByInternal, + sortBy: uiState.sortBy, setSortBy, - viewSettings, + viewSettings: uiState.viewSettings, setViewSettings, - sidebarVisible, - inspectorVisible, - quickPreviewFileId, + sidebarVisible: uiState.sidebarVisible, + setSidebarVisible, + inspectorVisible: uiState.inspectorVisible, + setInspectorVisible, + quickPreviewFileId: uiState.quickPreviewFileId, openQuickPreview, closeQuickPreview, currentFiles, - tagModeActive, + setCurrentFiles, + tagModeActive: uiState.tagModeActive, + setTagModeActive, devices, - setSpaceItemIdFromSidebar, + loadPreferencesForSpaceItem, + }), + [ + currentTarget, + currentPath, + currentView, + navigateToPath, + navigateToView, + goBack, + goForward, + canGoBack, + canGoForward, + uiState.viewMode, + setViewMode, + uiState.sortBy, + setSortBy, + uiState.viewSettings, + setViewSettings, + uiState.sidebarVisible, + setSidebarVisible, + uiState.inspectorVisible, + setInspectorVisible, + uiState.quickPreviewFileId, + openQuickPreview, + closeQuickPreview, + currentFiles, + uiState.tagModeActive, + setTagModeActive, + devices, + loadPreferencesForSpaceItem, ], ); @@ -492,11 +588,17 @@ export function ExplorerProvider({ ); } -export function useExplorer() { +export function useExplorer(): ExplorerContextValue { const context = useContext(ExplorerContext); - if (!context) - throw new Error("useExplorer must be used within ExplorerProvider"); + if (!context) { + throw new Error("useExplorer must be used within an ExplorerProvider"); + } return context; } -export { getSpaceItemKeyFromRoute }; +export { + getSpaceItemKey, + getSpaceItemKey as getSpaceItemKeyFromRoute, + targetToKey, + targetsEqual, +}; diff --git a/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts b/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts index 13472cd81..d59ae53d1 100644 --- a/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts +++ b/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts @@ -6,7 +6,7 @@ import type { DirectorySortBy } from "@sd/ts-client"; import { useTypeaheadSearch } from "./useTypeaheadSearch"; export function useExplorerKeyboard() { - const { currentPath, sortBy, setCurrentPath, viewMode, viewSettings, sidebarVisible, inspectorVisible, openQuickPreview, tagModeActive, setTagModeActive } = useExplorer(); + const { currentPath, sortBy, navigateToPath, viewMode, viewSettings, sidebarVisible, inspectorVisible, openQuickPreview, tagModeActive, setTagModeActive } = useExplorer(); const { selectedFiles, selectFile, selectAll, clearSelection, focusedIndex, setFocusedIndex, setSelectedFiles } = useSelection(); // Query files for keyboard operations @@ -98,7 +98,7 @@ export function useExplorerKeyboard() { const selected = selectedFiles[0]; if (selected.kind === "Directory") { e.preventDefault(); - setCurrentPath(selected.sd_path); + navigateToPath(selected.sd_path); } return; } @@ -134,7 +134,7 @@ export function useExplorerKeyboard() { inspectorVisible, selectAll, clearSelection, - setCurrentPath, + navigateToPath, setFocusedIndex, setSelectedFiles, openQuickPreview, diff --git a/packages/interface/src/components/Explorer/hooks/useFileContextMenu.ts b/packages/interface/src/components/Explorer/hooks/useFileContextMenu.ts index 817bceade..e851e0142 100644 --- a/packages/interface/src/components/Explorer/hooks/useFileContextMenu.ts +++ b/packages/interface/src/components/Explorer/hooks/useFileContextMenu.ts @@ -36,7 +36,7 @@ export function useFileContextMenu({ selectedFiles, selected, }: UseFileContextMenuProps) { - const { setCurrentPath, currentPath } = useExplorer(); + const { navigateToPath, currentPath } = useExplorer(); const platform = usePlatform(); const copyFiles = useLibraryMutation("files.copy"); const deleteFiles = useLibraryMutation("files.delete"); @@ -71,7 +71,7 @@ export function useFileContextMenu({ label: "Open", onClick: () => { if (file.kind === "Directory") { - setCurrentPath(file.sd_path); + navigateToPath(file.sd_path); } else { console.log("Open file:", file.name); // TODO: Implement file opening diff --git a/packages/interface/src/components/Explorer/index.ts b/packages/interface/src/components/Explorer/index.ts index 58a5a2093..ffb79dd62 100644 --- a/packages/interface/src/components/Explorer/index.ts +++ b/packages/interface/src/components/Explorer/index.ts @@ -1,4 +1,5 @@ -export { ExplorerProvider, useExplorer, getSpaceItemKeyFromRoute } from "./context"; +export { ExplorerProvider, useExplorer, getSpaceItemKey, getSpaceItemKeyFromRoute, targetToKey, targetsEqual } from "./context"; +export type { NavigationTarget, ViewMode, ViewSettings, SortBy } from "./context"; export { SelectionProvider, useSelection } from "./SelectionContext"; export { Sidebar } from "./Sidebar"; export { ExplorerView } from "./ExplorerView"; diff --git a/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx b/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx index 50ac1a48b..febf7f6ef 100644 --- a/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx +++ b/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx @@ -9,7 +9,7 @@ import { useTypeaheadSearch } from "../../hooks/useTypeaheadSearch"; import { useVirtualListing } from "../../hooks/useVirtualListing"; export function ColumnView() { - const { currentPath, setCurrentPath, sortBy, viewSettings } = useExplorer(); + const { currentPath, navigateToPath, sortBy, viewSettings } = useExplorer(); const { files: virtualFiles, isVirtualView } = useVirtualListing(); const { selectedFiles, @@ -53,7 +53,7 @@ export function ColumnView() { if (!multi && !range) { if (file.kind === "Directory") { // Truncate columns after current and add new one - // DON'T call setCurrentPath - columnStack manages internal navigation + // DON'T call navigateToPath - columnStack manages internal navigation // This prevents ExplorerLayout from re-rendering on every column change setColumnStack((prev) => [ ...prev.slice(0, columnIndex + 1), @@ -70,9 +70,9 @@ export function ColumnView() { const handleNavigate = useCallback( (path: SdPath) => { - setCurrentPath(path); + navigateToPath(path); }, - [setCurrentPath], + [navigateToPath], ); // Find the active column (the one containing the first selected file) @@ -144,96 +144,105 @@ export function ColumnView() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Handle arrow keys - if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { + if ( + ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes( + e.key, + ) + ) { e.preventDefault(); if (e.key === "ArrowUp" || e.key === "ArrowDown") { - // Navigate within current column - if (activeColumnFiles.length === 0) return; + // Navigate within current column + if (activeColumnFiles.length === 0) return; - const currentIndex = - selectedFiles.length > 0 - ? activeColumnFiles.findIndex( - (f) => f.id === selectedFiles[0].id, - ) - : -1; - - const newIndex = - e.key === "ArrowDown" - ? currentIndex < 0 - ? 0 - : Math.min( - currentIndex + 1, - activeColumnFiles.length - 1, + const currentIndex = + selectedFiles.length > 0 + ? activeColumnFiles.findIndex( + (f) => f.id === selectedFiles[0].id, ) - : currentIndex < 0 - ? 0 - : Math.max(currentIndex - 1, 0); + : -1; - if (newIndex !== currentIndex && activeColumnFiles[newIndex]) { - const newFile = activeColumnFiles[newIndex]; - handleSelectFile( - newFile, - activeColumnIndex, - activeColumnFiles, - ); + const newIndex = + e.key === "ArrowDown" + ? currentIndex < 0 + ? 0 + : Math.min( + currentIndex + 1, + activeColumnFiles.length - 1, + ) + : currentIndex < 0 + ? 0 + : Math.max(currentIndex - 1, 0); - // Scroll to keep selection visible - const element = document.querySelector( - `[data-file-id="${newFile.id}"]`, - ); - if (element) { - element.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }); - } - } - } else if (e.key === "ArrowLeft") { - // Move to previous column - if (activeColumnIndex > 0) { - // Truncate columns and stay at previous column - // DON'T call setCurrentPath - columnStack manages internal navigation - setColumnStack((prev) => prev.slice(0, activeColumnIndex)); - clearSelectionRef.current(); - } - } else if (e.key === "ArrowRight") { - // If selected file is a directory and there's a next column, move focus there - const firstSelected = selectedFiles[0]; - if ( - firstSelected?.kind === "Directory" && - activeColumnIndex < columnStack.length - 1 - ) { - // Select first item in next column - if (nextColumnFiles.length > 0) { - const firstFile = nextColumnFiles[0]; + if ( + newIndex !== currentIndex && + activeColumnFiles[newIndex] + ) { + const newFile = activeColumnFiles[newIndex]; handleSelectFile( - firstFile, - activeColumnIndex + 1, - nextColumnFiles, + newFile, + activeColumnIndex, + activeColumnFiles, ); // Scroll to keep selection visible - setTimeout(() => { - const element = document.querySelector( - `[data-file-id="${firstFile.id}"]`, + const element = document.querySelector( + `[data-file-id="${newFile.id}"]`, + ); + if (element) { + element.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + } + } else if (e.key === "ArrowLeft") { + // Move to previous column + if (activeColumnIndex > 0) { + // Truncate columns and stay at previous column + // DON'T call navigateToPath - columnStack manages internal navigation + setColumnStack((prev) => + prev.slice(0, activeColumnIndex), + ); + clearSelectionRef.current(); + } + } else if (e.key === "ArrowRight") { + // If selected file is a directory and there's a next column, move focus there + const firstSelected = selectedFiles[0]; + if ( + firstSelected?.kind === "Directory" && + activeColumnIndex < columnStack.length - 1 + ) { + // Select first item in next column + if (nextColumnFiles.length > 0) { + const firstFile = nextColumnFiles[0]; + handleSelectFile( + firstFile, + activeColumnIndex + 1, + nextColumnFiles, ); - if (element) { - element.scrollIntoView({ - block: "nearest", - behavior: "smooth", - }); - } - }, 0); + + // Scroll to keep selection visible + setTimeout(() => { + const element = document.querySelector( + `[data-file-id="${firstFile.id}"]`, + ); + if (element) { + element.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, 0); + } } } + return; } - return; - } - // Typeahead search for active column - typeahead.handleKey(e); - }; + // Typeahead search for active column + typeahead.handleKey(e); + }; window.addEventListener("keydown", handleKeyDown); return () => { diff --git a/packages/interface/src/components/Explorer/views/GridView/FileCard.tsx b/packages/interface/src/components/Explorer/views/GridView/FileCard.tsx index b72ae3efb..2138610a1 100644 --- a/packages/interface/src/components/Explorer/views/GridView/FileCard.tsx +++ b/packages/interface/src/components/Explorer/views/GridView/FileCard.tsx @@ -37,7 +37,7 @@ export const FileCard = memo( selectedFiles, selectFile, }: FileCardProps) { - const { viewSettings, setCurrentPath } = useExplorer(); + const { viewSettings, navigateToPath } = useExplorer(); const { gridSize, showFileSize } = viewSettings; const contextMenu = useFileContextMenu({ @@ -55,13 +55,13 @@ export const FileCard = memo( const handleDoubleClick = () => { // Virtual files (locations, volumes, devices) always navigate to their sd_path if (isVirtualFile(file) && file.sd_path) { - setCurrentPath(file.sd_path); + navigateToPath(file.sd_path); return; } // Regular directories navigate normally if (file.kind === "Directory") { - setCurrentPath(file.sd_path); + navigateToPath(file.sd_path); } }; diff --git a/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx b/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx index 021d78274..85e9a8655 100644 --- a/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx +++ b/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx @@ -43,7 +43,7 @@ export const TableRow = memo( measureRef, selectFile, }: TableRowProps) { - const { setCurrentPath } = useExplorer(); + const { navigateToPath } = useExplorer(); const { selectedFiles } = useSelection(); const contextMenu = useFileContextMenu({ @@ -64,15 +64,15 @@ export const TableRow = memo( const handleDoubleClick = useCallback(() => { // Virtual files (locations, volumes, devices) always navigate to their sd_path if (isVirtualFile(file) && file.sd_path) { - setCurrentPath(file.sd_path); + navigateToPath(file.sd_path); return; } // Regular directories navigate normally if (file.kind === "Directory") { - setCurrentPath(file.sd_path); + navigateToPath(file.sd_path); } - }, [file, setCurrentPath]); + }, [file, navigateToPath]); const handleContextMenu = useCallback( async (e: React.MouseEvent) => { diff --git a/packages/interface/src/components/Explorer/views/SizeView/SizeView.tsx b/packages/interface/src/components/Explorer/views/SizeView/SizeView.tsx index cdd505ed3..acf5f38b5 100644 --- a/packages/interface/src/components/Explorer/views/SizeView/SizeView.tsx +++ b/packages/interface/src/components/Explorer/views/SizeView/SizeView.tsx @@ -115,7 +115,7 @@ function getFileType(file: File): string { } export function SizeView() { - const { currentPath, sortBy, setCurrentPath, viewSettings } = useExplorer(); + const { currentPath, sortBy, navigateToPath, viewSettings } = useExplorer(); const { selectedFiles, selectFile } = useSelection(); const directoryQuery = useNormalizedQuery({ @@ -156,7 +156,7 @@ export function SizeView() { // Use refs for stable function references const selectFileRef = useRef(selectFile); - const setCurrentPathRef = useRef(setCurrentPath); + const navigateToPathRef = useRef(navigateToPath); const filesRef = useRef(files); const gRef = useRef { selectFileRef.current = selectFile; - setCurrentPathRef.current = setCurrentPath; + navigateToPathRef.current = navigateToPath; filesRef.current = files; contextMenuRef.current = contextMenu; - }, [selectFile, setCurrentPath, files, contextMenu]); + }, [selectFile, navigateToPath, files, contextMenu]); // Initialize zoom behavior once useEffect(() => { @@ -309,6 +309,14 @@ export function SizeView() { }; }, []); // Only run once + // Reset zoom when path changes + useEffect(() => { + if (!svgRef.current || !zoomBehaviorRef.current) return; + const svg = d3.select(svgRef.current); + svg.call(zoomBehaviorRef.current.transform, d3.zoomIdentity); + setCurrentZoom(1); + }, [currentPath]); + const bubbleData = useMemo(() => { const filesWithSize = files.filter((f) => f.size > 0); @@ -377,49 +385,39 @@ export function SizeView() { .on("click", (event, d) => { event.stopPropagation(); - // Clear any existing timeout + const multi = event.metaKey || event.ctrlKey; + const range = event.shiftKey; + + // Select immediately for responsive feedback + selectFileRef.current( + d.data.file, + filesRef.current, + multi, + range, + ); + + // Clear any existing zoom timeout if (clickTimeoutRef.current) { clearTimeout(clickTimeoutRef.current); clickTimeoutRef.current = null; } - // Set timeout for single click - clickTimeoutRef.current = setTimeout(() => { - const multi = event.metaKey || event.ctrlKey; - const range = event.shiftKey; - selectFileRef.current( - d.data.file, - filesRef.current, - multi, - range, - ); + // Delay zoom-to-focus to allow double-click detection + if (!multi && !range && svgRef.current && zoomBehaviorRef.current) { + clickTimeoutRef.current = setTimeout(() => { + if (!svgRef.current || !zoomBehaviorRef.current) return; - // Zoom to center this circle - if ( - !multi && - !range && - svgRef.current && - zoomBehaviorRef.current - ) { const svgElement = svgRef.current; const width = svgElement.clientWidth; const height = svgElement.clientHeight; - - // Calculate the transform needed to center this circle - const currentTransform = d3.zoomTransform(svgElement); const centerX = width / 2; const centerY = height / 2; // Target: make the bubble appear at a consistent size on screen - // regardless of its original size - const targetBubbleScreenSize = - Math.min(width, height) * 0.4; // 40% of viewport - const bubbleSize = d.r * 2; // diameter in data coordinates - - // Calculate what scale would make this bubble that size on screen + const targetBubbleScreenSize = Math.min(width, height) * 0.4; + const bubbleSize = d.r * 2; const targetScale = targetBubbleScreenSize / bubbleSize; - // Create new transform const newTransform = d3.zoomIdentity .translate(centerX, centerY) .scale(targetScale) @@ -427,13 +425,13 @@ export function SizeView() { d3.select(svgElement) .transition() - .duration(500) + .duration(400) .call( - zoomBehaviorRef.current.transform, + zoomBehaviorRef.current!.transform, newTransform, ); - } - }, 250); // 250ms delay to detect double click + }, 200); + } }) .on("dblclick", (event, d) => { event.stopPropagation(); @@ -446,7 +444,7 @@ export function SizeView() { // Navigate if directory if (d.data.file.kind === "Directory") { - setCurrentPathRef.current(d.data.file.sd_path); + navigateToPathRef.current(d.data.file.sd_path); } }) .on("contextmenu", async (event, d) => { diff --git a/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx b/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx index 7219d3129..f3ffda2ce 100644 --- a/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx @@ -18,7 +18,7 @@ export function DevicesGroup({ sortableAttributes, sortableListeners, }: DevicesGroupProps) { - const { navigateToView, setSpaceItemIdFromSidebar } = useExplorer(); + const { navigateToView, loadPreferencesForSpaceItem } = useExplorer(); // Use normalized query for automatic updates when device events are emitted const { data: devices, isLoading } = useNormalizedQuery< @@ -107,7 +107,7 @@ export function DevicesGroup({ customIcon={getDeviceIcon(device)} customLabel={device.name} onClick={() => { - setSpaceItemIdFromSidebar(`device:${device.id}`); + loadPreferencesForSpaceItem(`device:${device.id}`); navigateToView("device", device.id); }} onContextMenu={handleDeviceContextMenu(device)} diff --git a/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx b/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx index b0260c537..c759b634b 100644 --- a/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx +++ b/packages/interface/src/components/SpacesSidebar/SpaceItem.tsx @@ -167,7 +167,7 @@ export function SpaceItem({ className, }: SpaceItemProps) { const navigate = useNavigate(); - const { setSpaceItemIdFromSidebar } = useExplorer(); + const { loadPreferencesForSpaceItem } = useExplorer(); // Merge legacy props into overrides const effectiveOverrides: SpaceItemOverrides = { @@ -244,7 +244,7 @@ export function SpaceItem({ ? [path.split("?")[0], "?" + path.split("?")[1]] : [path, ""]; const spaceItemKey = getSpaceItemKeyFromRoute(pathname, search); - setSpaceItemIdFromSidebar(spaceItemKey); + loadPreferencesForSpaceItem(spaceItemKey); navigate(path); } }; diff --git a/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx b/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx index f87016278..f672bd6f9 100644 --- a/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/TagsGroup.tsx @@ -21,7 +21,7 @@ interface TagItemProps { function TagItem({ tag, depth = 0 }: TagItemProps) { const navigate = useNavigate(); - const { setSpaceItemIdFromSidebar } = useExplorer(); + const { loadPreferencesForSpaceItem } = useExplorer(); const [isExpanded, setIsExpanded] = useState(false); // TODO: Fetch children when hierarchy is implemented @@ -29,7 +29,7 @@ function TagItem({ tag, depth = 0 }: TagItemProps) { const hasChildren = children.length > 0; const handleClick = () => { - setSpaceItemIdFromSidebar(`tag:${tag.id}`); + loadPreferencesForSpaceItem(`tag:${tag.id}`); navigate(`/tag/${tag.id}`); }; @@ -91,7 +91,7 @@ export function TagsGroup({ sortableListeners, }: TagsGroupProps) { const navigate = useNavigate(); - const { setSpaceItemIdFromSidebar } = useExplorer(); + const { loadPreferencesForSpaceItem } = useExplorer(); const [isCreating, setIsCreating] = useState(false); const [newTagName, setNewTagName] = useState(''); @@ -119,7 +119,7 @@ export function TagsGroup({ // Navigate to the new tag if (result?.tag?.id) { - setSpaceItemIdFromSidebar(`tag:${result.tag.id}`); + loadPreferencesForSpaceItem(`tag:${result.tag.id}`); navigate(`/tag/${result.tag.id}`); } diff --git a/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts index 409523629..08ac61986 100644 --- a/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts +++ b/packages/interface/src/components/SpacesSidebar/hooks/useSpaceItemContextMenu.ts @@ -38,7 +38,7 @@ export function useSpaceItemContextMenu({ }: UseSpaceItemContextMenuOptions): ContextMenuResult { const navigate = useNavigate(); const platform = usePlatform(); - const { setSpaceItemIdFromSidebar } = useExplorer(); + const { loadPreferencesForSpaceItem } = useExplorer(); const deleteItem = useLibraryMutation("spaces.delete_item"); const indexVolume = useLibraryMutation("volumes.index"); @@ -53,7 +53,7 @@ export function useSpaceItemContextMenu({ ? [path.split("?")[0], "?" + path.split("?")[1]] : [path, ""]; const spaceItemKey = getSpaceItemKeyFromRoute(pathname, search); - setSpaceItemIdFromSidebar(spaceItemKey); + loadPreferencesForSpaceItem(spaceItemKey); navigate(path); } }, From dabb1952919fc5d341901555df763ebc7d76c9c7 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 22 Dec 2025 05:55:27 -0800 Subject: [PATCH 09/12] Refactor Explorer component and enhance keyboard navigation - Simplified import statements in Explorer component for better readability. - Updated QuickPreviewSyncer to use openQuickPreview instead of setQuickPreviewFileId for improved clarity. - Enhanced keyboard navigation handling in MediaView to support arrow key navigation, allowing users to select files more intuitively. - Adjusted useExplorerKeyboard to skip media view in keyboard navigation, ensuring a smoother user experience. --- packages/interface/src/Explorer.tsx | 15 +++--- .../Explorer/hooks/useExplorerKeyboard.ts | 4 +- .../Explorer/views/MediaView/MediaView.tsx | 46 ++++++++++++++++++- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/interface/src/Explorer.tsx b/packages/interface/src/Explorer.tsx index f49dc175c..70f39f5b5 100644 --- a/packages/interface/src/Explorer.tsx +++ b/packages/interface/src/Explorer.tsx @@ -12,11 +12,7 @@ import { Dialogs } from "@sd/ui"; import { Inspector, type InspectorVariant } from "./Inspector"; import { TopBarProvider, TopBar } from "./TopBar"; import { motion, AnimatePresence } from "framer-motion"; -import { - ExplorerProvider, - useExplorer, - Sidebar, -} from "./components/Explorer"; +import { ExplorerProvider, useExplorer, Sidebar } from "./components/Explorer"; import { SelectionProvider, useSelection, @@ -64,7 +60,7 @@ import { House, Clock, Heart, Folders } from "@phosphor-icons/react"; * we update the preview to show the newly selected file. */ function QuickPreviewSyncer() { - const { quickPreviewFileId, setQuickPreviewFileId } = useExplorer(); + const { quickPreviewFileId, openQuickPreview } = useExplorer(); const { selectedFiles } = useSelection(); useEffect(() => { @@ -75,9 +71,9 @@ function QuickPreviewSyncer() { selectedFiles.length === 1 && selectedFiles[0].id !== quickPreviewFileId ) { - setQuickPreviewFileId(selectedFiles[0].id); + openQuickPreview(selectedFiles[0].id); } - }, [selectedFiles, quickPreviewFileId, setQuickPreviewFileId]); + }, [selectedFiles, quickPreviewFileId, openQuickPreview]); return null; } @@ -758,7 +754,8 @@ function DndWrapper({ children }: { children: React.ReactNode }) { {activeItem.itemType === "Overview" && "Overview"} {activeItem.itemType === "Recents" && "Recents"} {activeItem.itemType === "Favorites" && "Favorites"} - {activeItem.itemType === "FileKinds" && "File Kinds"} + {activeItem.itemType === "FileKinds" && + "File Kinds"}
) : activeItem?.label ? ( diff --git a/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts b/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts index d59ae53d1..c9569c727 100644 --- a/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts +++ b/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts @@ -41,8 +41,8 @@ export function useExplorerKeyboard() { const handleKeyDown = async (e: KeyboardEvent) => { // Arrow keys: Navigation if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { - // Skip column view - each column handles its own keyboard navigation - if (viewMode === "column") { + // Skip views that handle their own keyboard navigation + if (viewMode === "column" || viewMode === "media") { return; } diff --git a/packages/interface/src/components/Explorer/views/MediaView/MediaView.tsx b/packages/interface/src/components/Explorer/views/MediaView/MediaView.tsx index 03cbd7f4b..8d6657f70 100644 --- a/packages/interface/src/components/Explorer/views/MediaView/MediaView.tsx +++ b/packages/interface/src/components/Explorer/views/MediaView/MediaView.tsx @@ -10,6 +10,7 @@ import { import { useExplorer } from "../../context"; import { useSelection } from "../../SelectionContext"; import { useNormalizedQuery } from "../../../../context"; +import type { File } from "@sd/ts-client"; import { MediaViewItem } from "./MediaViewItem"; import { DateHeader, DATE_HEADER_HEIGHT } from "./DateHeader"; import { formatDate, getItemDate, normalizeDateToMidnight } from "./utils"; @@ -22,7 +23,7 @@ export function MediaView() { setSortBy, setCurrentFiles, } = useExplorer(); - const { selectedFiles, selectFile, focusedIndex, isSelected, selectedFileIds } = useSelection(); + const { selectedFiles, selectFile, focusedIndex, setFocusedIndex, setSelectedFiles, isSelected, selectedFileIds } = useSelection(); // Set default sort to "datetaken" when entering media view useEffect(() => { @@ -145,6 +146,49 @@ export function MediaView() { } }, [files, elementReady]); + // Keyboard navigation for media view + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { + return; + } + if (files.length === 0) return; + + e.preventDefault(); + + // Calculate columns based on container width + const itemWidth = gridSize + gapSize; + const cols = containerWidth > 0 + ? Math.max(4, Math.floor(containerWidth / itemWidth)) + : 8; + + let newIndex = focusedIndex; + + if (e.key === "ArrowUp") { + newIndex = Math.max(0, focusedIndex - cols); + } else if (e.key === "ArrowDown") { + newIndex = Math.min(files.length - 1, focusedIndex + cols); + } else if (e.key === "ArrowLeft") { + newIndex = Math.max(0, focusedIndex - 1); + } else if (e.key === "ArrowRight") { + newIndex = Math.min(files.length - 1, focusedIndex + 1); + } + + if (newIndex !== focusedIndex && files[newIndex]) { + setFocusedIndex(newIndex); + setSelectedFiles([files[newIndex]]); + + // Scroll selected item into view + const element = document.querySelector(`[data-file-id="${files[newIndex].id}"]`); + if (element) { + element.scrollIntoView({ block: "nearest", behavior: "smooth" }); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [files, focusedIndex, gridSize, gapSize, containerWidth, setFocusedIndex, setSelectedFiles]); // Calculate columns and actual item size to fill available space const { columns, actualItemSize } = useMemo(() => { From 9925d926e29dced75c039a066124514f39b29da0 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 22 Dec 2025 07:04:04 -0800 Subject: [PATCH 10/12] Enhance device revocation process with library removal option - Added `remove_from_library` field to `DeviceRevokeInput` and `DeviceRevokeAction` to control whether a device should be removed from library databases during revocation. - Updated the `from_input` method in `DeviceRevokeAction` to handle the new field. - Modified the revocation logic to conditionally remove devices from libraries based on the `remove_from_library` flag, improving flexibility in device management. - Adjusted the CLI argument parsing to default `remove_from_library` to false, ensuring devices remain in libraries unless explicitly specified for removal. --- apps/cli/src/domains/network/args.rs | 1 + core/src/ops/network/revoke/action.rs | 86 +-- core/src/ops/network/revoke/input.rs | 7 + .../src/components/Explorer/File/File.tsx | 99 ++-- .../Explorer/hooks/useExplorerKeyboard.ts | 2 +- .../Explorer/views/ColumnView/ColumnItem.tsx | 2 +- .../Explorer/views/GridView/FileCard.tsx | 3 +- .../Explorer/views/GridView/GridView.tsx | 473 +++++++++-------- .../Explorer/views/ListView/TableRow.tsx | 3 +- .../Explorer/views/MediaView/MediaView.tsx | 3 +- .../views/MediaView/MediaViewItem.tsx | 3 +- .../QuickPreview/ContentRenderer.tsx | 19 +- .../QuickPreview/QuickPreviewFullscreen.tsx | 73 ++- .../QuickPreview/TimelineScrubber.tsx | 17 +- .../components/QuickPreview/VideoControls.tsx | 288 ++++++++++ .../components/QuickPreview/VideoPlayer.tsx | 493 ++++++++---------- .../components/SpacesSidebar/DevicesGroup.tsx | 12 + 17 files changed, 994 insertions(+), 590 deletions(-) create mode 100644 packages/interface/src/components/QuickPreview/VideoControls.tsx diff --git a/apps/cli/src/domains/network/args.rs b/apps/cli/src/domains/network/args.rs index 03291fc7a..9b3d25a0f 100644 --- a/apps/cli/src/domains/network/args.rs +++ b/apps/cli/src/domains/network/args.rs @@ -119,6 +119,7 @@ impl From for DeviceRevokeInput { fn from(args: RevokeArgs) -> Self { Self { device_id: args.device_id, + remove_from_library: false, } } } diff --git a/core/src/ops/network/revoke/action.rs b/core/src/ops/network/revoke/action.rs index e54819605..b8c743b95 100644 --- a/core/src/ops/network/revoke/action.rs +++ b/core/src/ops/network/revoke/action.rs @@ -4,6 +4,7 @@ use std::sync::Arc; pub struct DeviceRevokeAction { pub device_id: uuid::Uuid, + pub remove_from_library: bool, } impl CoreAction for DeviceRevokeAction { @@ -13,6 +14,7 @@ impl CoreAction for DeviceRevokeAction { fn from_input(input: Self::Input) -> std::result::Result { Ok(Self { device_id: input.device_id, + remove_from_library: input.remove_from_library, }) } @@ -68,52 +70,62 @@ impl CoreAction for DeviceRevokeAction { } } - // Remove from all library databases - tracing::info!("Removing device {} from library databases", self.device_id); - let libraries = context.libraries().await; - let mut removed_from_libraries = 0; + // Remove from all library databases (if requested) + if self.remove_from_library { + tracing::info!( + "Removing device {} from library databases (remove_from_library=true)", + self.device_id + ); + let libraries = context.libraries().await; + let mut removed_from_libraries = 0; - for library in libraries.get_open_libraries().await { - let db = library.db().conn(); + for library in libraries.get_open_libraries().await { + let db = library.db().conn(); - // Delete device from library database - use crate::infra::db::entities::device; - use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + // Delete device from library database + use crate::infra::db::entities::device; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; - match device::Entity::delete_many() - .filter(device::Column::Uuid.eq(self.device_id)) - .exec(db) - .await - { - Ok(result) => { - if result.rows_affected > 0 { - tracing::info!( - "Device {} removed from library {} database", - self.device_id, - library.id() + match device::Entity::delete_many() + .filter(device::Column::Uuid.eq(self.device_id)) + .exec(db) + .await + { + Ok(result) => { + if result.rows_affected > 0 { + tracing::info!( + "Device {} removed from library {} database", + self.device_id, + library.id() + ); + removed_from_libraries += 1; + } + } + Err(e) => { + tracing::warn!( + "Failed to remove device from library {} database: {}", + library.id(), + e ); - removed_from_libraries += 1; } } - Err(e) => { - tracing::warn!( - "Failed to remove device from library {} database: {}", - library.id(), - e - ); - } } - } - if removed_from_libraries > 0 { - tracing::info!( - "Device {} removed from {} library database(s)", - self.device_id, - removed_from_libraries - ); + if removed_from_libraries > 0 { + tracing::info!( + "Device {} removed from {} library database(s)", + self.device_id, + removed_from_libraries + ); + } else { + tracing::warn!( + "Device {} not found in any library databases (may have been removed already)", + self.device_id + ); + } } else { - tracing::warn!( - "Device {} not found in any library databases (may have been removed already)", + tracing::info!( + "Skipping library database removal for device {} (remove_from_library=false)", self.device_id ); } diff --git a/core/src/ops/network/revoke/input.rs b/core/src/ops/network/revoke/input.rs index b9288594c..baa6412d5 100644 --- a/core/src/ops/network/revoke/input.rs +++ b/core/src/ops/network/revoke/input.rs @@ -5,4 +5,11 @@ use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, Type)] pub struct DeviceRevokeInput { pub device_id: Uuid, + + /// Whether to also remove the device from all library databases + /// + /// If false (default), only unpairs from network but keeps device history in libraries. + /// If true, completely removes device from libraries (deletes all records). + #[serde(default)] + pub remove_from_library: bool, } diff --git a/packages/interface/src/components/Explorer/File/File.tsx b/packages/interface/src/components/Explorer/File/File.tsx index 60ce16e83..6c3fa5636 100644 --- a/packages/interface/src/components/Explorer/File/File.tsx +++ b/packages/interface/src/components/Explorer/File/File.tsx @@ -5,59 +5,62 @@ import { Title } from "./Title"; import { Metadata } from "./Metadata"; interface FileProps { - file: FileType; - selected?: boolean; - onClick?: (e: React.MouseEvent) => void; - onDoubleClick?: (e: React.MouseEvent) => void; - onContextMenu?: (e: React.MouseEvent) => void; - onMouseDown?: (e: React.MouseEvent) => void; - onMouseMove?: (e: React.MouseEvent) => void; - onMouseUp?: (e: React.MouseEvent) => void; - onMouseLeave?: (e: React.MouseEvent) => void; - layout?: "column" | "row"; - children?: React.ReactNode; - className?: string; - "data-file-id"?: string; + file: FileType; + selected?: boolean; + onClick?: (e: React.MouseEvent) => void; + onDoubleClick?: (e: React.MouseEvent) => void; + onContextMenu?: (e: React.MouseEvent) => void; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseMove?: (e: React.MouseEvent) => void; + onMouseUp?: (e: React.MouseEvent) => void; + onMouseLeave?: (e: React.MouseEvent) => void; + layout?: "column" | "row"; + children?: React.ReactNode; + className?: string; + "data-file-id"?: string; } function FileComponent({ - file, - selected, - onClick, - onDoubleClick, - onContextMenu, - onMouseDown, - onMouseMove, - onMouseUp, - onMouseLeave, - layout = "column", - children, - className, - "data-file-id": dataFileId, + file, + selected, + onClick, + onDoubleClick, + onContextMenu, + onMouseDown, + onMouseMove, + onMouseUp, + onMouseLeave, + layout = "column", + children, + className, + "data-file-id": dataFileId, }: FileProps) { - return ( -
- {children} -
- ); + return ( +
+ {children} +
+ ); } export const File = Object.assign(FileComponent, { - Thumb, - Title, - Metadata, + Thumb, + Title, + Metadata, }); diff --git a/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts b/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts index c9569c727..5e092c6df 100644 --- a/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts +++ b/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts @@ -42,7 +42,7 @@ export function useExplorerKeyboard() { // Arrow keys: Navigation if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { // Skip views that handle their own keyboard navigation - if (viewMode === "column" || viewMode === "media") { + if (viewMode === "column" || viewMode === "media" || viewMode === "grid") { return; } diff --git a/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx b/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx index d33280fbd..09e0f9a13 100644 --- a/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx +++ b/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx @@ -42,7 +42,7 @@ export const ColumnItem = memo( }); return ( -
+
{/* Drop indicator for folders */} {isFolder && isDropOver && ( diff --git a/packages/interface/src/components/Explorer/views/GridView/GridView.tsx b/packages/interface/src/components/Explorer/views/GridView/GridView.tsx index 030af8bac..b9bd7c102 100644 --- a/packages/interface/src/components/Explorer/views/GridView/GridView.tsx +++ b/packages/interface/src/components/Explorer/views/GridView/GridView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useExplorer } from "../../context"; import { useSelection } from "../../SelectionContext"; @@ -10,240 +10,305 @@ import { useVirtualListing } from "../../hooks/useVirtualListing"; const VIRTUALIZATION_THRESHOLD = 0; // Disabled - always virtualize export function GridView() { - const { currentPath, sortBy, viewSettings, setCurrentFiles } = useExplorer(); - const { isSelected, focusedIndex, selectedFiles, selectFile, clearSelection } = useSelection(); - const { gridSize, gapSize } = viewSettings; + const { currentPath, sortBy, viewSettings, setCurrentFiles } = + useExplorer(); + const { + isSelected, + focusedIndex, + setFocusedIndex, + selectedFiles, + selectFile, + clearSelection, + setSelectedFiles, + } = useSelection(); + const { gridSize, gapSize } = viewSettings; - // Check for virtual listing first - const { files: virtualFiles, isVirtualView } = useVirtualListing(); + // Check for virtual listing first + const { files: virtualFiles, isVirtualView } = useVirtualListing(); - const directoryQuery = useNormalizedQuery({ - wireMethod: "query:files.directory_listing", - input: currentPath - ? { - path: currentPath, - limit: null, - include_hidden: false, - sort_by: sortBy as DirectorySortBy, - folders_first: viewSettings.foldersFirst, - } - : null!, - resourceType: "file", - enabled: !!currentPath && !isVirtualView, - pathScope: currentPath ?? undefined, - }); + const directoryQuery = useNormalizedQuery({ + wireMethod: "query:files.directory_listing", + input: currentPath + ? { + path: currentPath, + limit: null, + include_hidden: false, + sort_by: sortBy as DirectorySortBy, + folders_first: viewSettings.foldersFirst, + } + : null!, + resourceType: "file", + enabled: !!currentPath && !isVirtualView, + pathScope: currentPath ?? undefined, + }); - const files = isVirtualView ? (virtualFiles || []) : (directoryQuery.data?.files || []); + const files = isVirtualView + ? virtualFiles || [] + : (directoryQuery.data as any)?.files || []; - // Update current files in explorer context for quick preview navigation - useEffect(() => { - setCurrentFiles(files); - }, [files, setCurrentFiles]); + // Update current files in explorer context for quick preview navigation + useEffect(() => { + setCurrentFiles(files); + }, [files, setCurrentFiles]); - const handleContainerClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - clearSelection(); - } - }; + const handleContainerClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + clearSelection(); + } + }; - // Conditional virtualization - use simple grid for small directories - const shouldVirtualize = files.length > VIRTUALIZATION_THRESHOLD; + // Conditional virtualization - use simple grid for small directories + const shouldVirtualize = files.length > VIRTUALIZATION_THRESHOLD; - if (!shouldVirtualize) { - return ( -
- {files.map((file, index) => ( - - ))} -
- ); - } + if (!shouldVirtualize) { + return ( +
+ {files.map((file, index) => ( + + ))} +
+ ); + } - return ( - - ); + return ( + + ); } interface VirtualizedGridProps { - files: File[]; - gridSize: number; - gapSize: number; - isSelected: (id: string) => boolean; - focusedIndex: number; - selectedFiles: File[]; - selectFile: (file: File, files: File[], multi?: boolean, range?: boolean) => void; - onContainerClick: (e: React.MouseEvent) => void; + files: File[]; + gridSize: number; + gapSize: number; + isSelected: (id: string) => boolean; + focusedIndex: number; + setFocusedIndex: (index: number) => void; + selectedFiles: File[]; + selectFile: ( + file: File, + files: File[], + multi?: boolean, + range?: boolean, + ) => void; + setSelectedFiles: (files: File[]) => void; + onContainerClick: (e: React.MouseEvent) => void; } function VirtualizedGrid({ - files, - gridSize, - gapSize, - isSelected, - focusedIndex, - selectedFiles, - selectFile, - onContainerClick, + files, + gridSize, + gapSize, + isSelected, + focusedIndex, + setFocusedIndex, + selectedFiles, + selectFile, + setSelectedFiles, + onContainerClick, }: VirtualizedGridProps) { - const parentRef = useRef(null); - const [containerWidth, setContainerWidth] = useState(0); + const parentRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); - // Track container width with ResizeObserver - useEffect(() => { - const element = parentRef.current; - if (!element) return; + // Synchronous measurement before paint to prevent layout shift + useLayoutEffect(() => { + const element = parentRef.current; + if (!element) return; - let rafId: number | null = null; + const updateWidth = () => { + const newWidth = element.offsetWidth; - const updateWidth = () => { - if (rafId) return; + if (newWidth > 0) { + setContainerWidth(newWidth - 24); + setIsInitialized(true); + } + }; - rafId = requestAnimationFrame(() => { - rafId = null; - const newWidth = element.offsetWidth; + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(element); - if (newWidth > 0) { - // Subtract padding (p-3 = 12px on each side) - setContainerWidth(newWidth - 24); - } - }); - }; + // Immediate measurement + updateWidth(); - const resizeObserver = new ResizeObserver(updateWidth); - resizeObserver.observe(element); - window.addEventListener("resize", updateWidth); + return () => { + resizeObserver.disconnect(); + }; + }, []); - // Set initial width - updateWidth(); + // Calculate columns (mimic auto-fill behavior) + const columns = useMemo(() => { + if (!containerWidth) return 1; - return () => { - if (rafId) cancelAnimationFrame(rafId); - resizeObserver.disconnect(); - window.removeEventListener("resize", updateWidth); - }; - }, []); + // Mimic repeat(auto-fill, minmax(gridSize, 1fr)) + const minItemWidth = gridSize; + const totalGapWidth = gapSize; - // Calculate columns (mimic auto-fill behavior) - const columns = useMemo(() => { - if (!containerWidth) return 1; + // Calculate how many items fit + let cols = 1; + while (true) { + const totalGaps = (cols - 1) * gapSize; + const requiredWidth = cols * minItemWidth + totalGaps; - // Mimic repeat(auto-fill, minmax(gridSize, 1fr)) - const minItemWidth = gridSize; - const totalGapWidth = gapSize; + if (requiredWidth <= containerWidth) { + cols++; + } else { + cols--; + break; + } + } - // Calculate how many items fit - let cols = 1; - while (true) { - const totalGaps = (cols - 1) * gapSize; - const requiredWidth = cols * minItemWidth + totalGaps; + return Math.max(1, cols); + }, [containerWidth, gridSize, gapSize]); - if (requiredWidth <= containerWidth) { - cols++; - } else { - cols--; - break; - } - } + const rowCount = Math.ceil(files.length / columns); + const rowGap = 4; // Gap between rows - return Math.max(1, cols); - }, [containerWidth, gridSize, gapSize]); + // Row virtualizer + const rowVirtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => parentRef.current, + estimateSize: () => gridSize + gapSize + rowGap, + overscan: 5, + }); - const rowCount = Math.ceil(files.length / columns); - const rowGap = 4; // Gap between rows + const virtualRows = rowVirtualizer.getVirtualItems(); - // Row virtualizer - const rowVirtualizer = useVirtualizer({ - count: rowCount, - getScrollElement: () => parentRef.current, - estimateSize: () => gridSize + gapSize + rowGap, - overscan: 5, - }); + // Keyboard navigation with correct column count + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes( + e.key, + ) + ) { + return; + } + if (files.length === 0) return; - const virtualRows = rowVirtualizer.getVirtualItems(); + e.preventDefault(); - return ( -
-
- {virtualRows.map((virtualRow) => { - const startIndex = virtualRow.index * columns; - const endIndex = Math.min(startIndex + columns, files.length); - const rowFiles = files.slice(startIndex, endIndex); + let newIndex = focusedIndex < 0 ? 0 : focusedIndex; - return ( -
- {/* CSS Grid within row - preserves flex-to-fill */} -
- {rowFiles.map((file, idx) => { - const fileIndex = startIndex + idx; - return ( - - ); - })} -
-
- ); - })} -
-
- ); + if (e.key === "ArrowUp") { + newIndex = Math.max(0, newIndex - columns); + } else if (e.key === "ArrowDown") { + newIndex = Math.min(files.length - 1, newIndex + columns); + } else if (e.key === "ArrowLeft") { + newIndex = Math.max(0, newIndex - 1); + } else if (e.key === "ArrowRight") { + newIndex = Math.min(files.length - 1, newIndex + 1); + } + + if (newIndex !== focusedIndex && files[newIndex]) { + setFocusedIndex(newIndex); + setSelectedFiles([files[newIndex]]); + + // Scroll into view + const element = document.querySelector( + `[data-file-id="${files[newIndex].id}"]`, + ); + if (element) { + element.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [files, focusedIndex, columns, setFocusedIndex, setSelectedFiles]); + + return ( +
+
+ {virtualRows.map((virtualRow) => { + const startIndex = virtualRow.index * columns; + const endIndex = Math.min( + startIndex + columns, + files.length, + ); + const rowFiles = files.slice(startIndex, endIndex); + + return ( +
+ {/* CSS Grid within row - preserves flex-to-fill */} +
+ {rowFiles.map((file, idx) => { + const fileIndex = startIndex + idx; + return ( + + ); + })} +
+
+ ); + })} +
+
+ ); } diff --git a/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx b/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx index 85e9a8655..36ac08c2d 100644 --- a/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx +++ b/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx @@ -95,7 +95,8 @@ export const TableRow = memo( ref={measureRef} data-index={index} data-file-id={file.id} - className="relative" + tabIndex={-1} + className="relative outline-none focus:outline-none" style={{ height: ROW_HEIGHT }} onClick={handleClick} onDoubleClick={handleDoubleClick} diff --git a/packages/interface/src/components/Explorer/views/MediaView/MediaView.tsx b/packages/interface/src/components/Explorer/views/MediaView/MediaView.tsx index 8d6657f70..f1d1d259b 100644 --- a/packages/interface/src/components/Explorer/views/MediaView/MediaView.tsx +++ b/packages/interface/src/components/Explorer/views/MediaView/MediaView.tsx @@ -391,7 +391,8 @@ export function MediaView() { return (
interface ContentRendererProps { file: File; onZoomChange?: (isZoomed: boolean) => void; + onVideoControlsStateChange?: (state: VideoControlsState) => void; + onShowVideoControlsChange?: (show: boolean) => void; + getVideoCallbacks?: (callbacks: VideoControlsCallbacks) => void; } function ImageRenderer({ file, onZoomChange }: ContentRendererProps) { @@ -388,7 +392,7 @@ function ImageRenderer({ file, onZoomChange }: ContentRendererProps) { ); } -function VideoRenderer({ file, onZoomChange }: ContentRendererProps) { +function VideoRenderer({ file, onZoomChange, onVideoControlsStateChange, onShowVideoControlsChange, getVideoCallbacks }: ContentRendererProps) { const platform = usePlatform(); const [videoUrl, setVideoUrl] = useState(null); const [shouldLoadVideo, setShouldLoadVideo] = useState(false); @@ -444,7 +448,14 @@ function VideoRenderer({ file, onZoomChange }: ContentRendererProps) { } return ( - + ); } @@ -614,7 +625,7 @@ function DefaultRenderer({ file }: ContentRendererProps) { ); } -export function ContentRenderer({ file, onZoomChange }: ContentRendererProps) { +export function ContentRenderer({ file, onZoomChange, onVideoControlsStateChange, onShowVideoControlsChange, getVideoCallbacks }: ContentRendererProps) { // Handle directories first if (file.kind.type === "Directory") { return ( @@ -639,7 +650,7 @@ export function ContentRenderer({ file, onZoomChange }: ContentRendererProps) { case "image": return ; case "video": - return ; + return ; case "audio": return ; case "mesh": diff --git a/packages/interface/src/components/QuickPreview/QuickPreviewFullscreen.tsx b/packages/interface/src/components/QuickPreview/QuickPreviewFullscreen.tsx index 3d46235ac..2f7edf4c2 100644 --- a/packages/interface/src/components/QuickPreview/QuickPreviewFullscreen.tsx +++ b/packages/interface/src/components/QuickPreview/QuickPreviewFullscreen.tsx @@ -1,12 +1,17 @@ import { createPortal } from "react-dom"; import { motion, AnimatePresence } from "framer-motion"; import { X, ArrowLeft, ArrowRight } from "@phosphor-icons/react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import type { File } from "@sd/ts-client"; -import { useNormalizedQuery } from "../../context"; import { ContentRenderer } from "./ContentRenderer"; +import { + VideoControls, + type VideoControlsState, + type VideoControlsCallbacks, +} from "./VideoControls"; import { TopBarPortal } from "../../TopBar"; import { getContentKind } from "../Explorer/utils"; +import { useExplorer } from "../Explorer/context"; interface QuickPreviewFullscreenProps { fileId: string; @@ -35,23 +40,27 @@ export function QuickPreviewFullscreen({ }: QuickPreviewFullscreenProps) { const [portalTarget, setPortalTarget] = useState(null); const [isZoomed, setIsZoomed] = useState(false); + const [videoControlsState, setVideoControlsState] = + useState(null); + const [showVideoControls, setShowVideoControls] = useState(false); + const [videoCallbacks, setVideoCallbacks] = + useState(null); + const { currentFiles } = useExplorer(); // Reset zoom when file changes useEffect(() => { setIsZoomed(false); }, [fileId]); - const { - data: file, - isLoading, - error, - } = useNormalizedQuery<{ file_id: string }, File>({ - wireMethod: "query:files.by_id", - input: { file_id: fileId }, - resourceType: "file", - resourceId: fileId, - enabled: !!fileId && isOpen, - }); + // Get file directly from currentFiles - instant, no network request + const file = useMemo( + () => currentFiles.find((f) => f.id === fileId) ?? null, + [currentFiles, fileId], + ); + + // No query needed - files are already loaded by the explorer views + const isLoading = false; + const error = null; // Find portal target on mount useEffect(() => { @@ -107,11 +116,11 @@ export function QuickPreviewFullscreen({ transition={{ duration: 0.2 }} className={`absolute inset-0 flex flex-col ${getBackgroundClass()}`} > - {isLoading || !file ? ( + {!file && isLoading ? (
Loading...
- ) : error ? ( + ) : !file && error ? (
@@ -120,6 +129,10 @@ export function QuickPreviewFullscreen({
{error.message}
+ ) : !file ? ( +
+
File not found
+
) : ( <> {/* TopBar content via portal */} @@ -179,9 +192,39 @@ export function QuickPreviewFullscreen({
+ {/* Video Controls Overlay - fixed position, always uses sidebar/inspector padding */} + {videoControlsState && + videoCallbacks && + getContentKind(file) === "video" && ( +
+ +
+ )} + {/* Footer with keyboard hints */}
diff --git a/packages/interface/src/components/QuickPreview/TimelineScrubber.tsx b/packages/interface/src/components/QuickPreview/TimelineScrubber.tsx index d2bbe5a3d..f9ec61609 100644 --- a/packages/interface/src/components/QuickPreview/TimelineScrubber.tsx +++ b/packages/interface/src/components/QuickPreview/TimelineScrubber.tsx @@ -7,6 +7,8 @@ interface TimelineScrubberProps { hoverPercent: number; mouseX: number; duration: number; + sidebarWidth?: number; + inspectorWidth?: number; } /** @@ -20,6 +22,8 @@ export const TimelineScrubber = memo(function TimelineScrubber({ hoverPercent, mouseX, duration, + sidebarWidth = 0, + inspectorWidth = 0, }: TimelineScrubberProps) { const { buildSidecarUrl } = useServer(); @@ -75,12 +79,15 @@ export const TimelineScrubber = memo(function TimelineScrubber({ const previewWidth = 160; const previewHeight = 90; - // Position horizontally following mouse, clamped to screen bounds + // Position horizontally following mouse, clamped to controls bounds + // Adjust for sidebar offset and clamp within the controls area + const controlsWidth = window.innerWidth - sidebarWidth - inspectorWidth; + const mouseXRelativeToControls = mouseX - sidebarWidth; const leftPosition = Math.max( 10, Math.min( - mouseX - previewWidth / 2, - window.innerWidth - previewWidth - 10, + mouseXRelativeToControls - previewWidth / 2, + controlsWidth - previewWidth - 10, ), ); @@ -89,10 +96,10 @@ export const TimelineScrubber = memo(function TimelineScrubber({ return (
diff --git a/packages/interface/src/components/QuickPreview/VideoControls.tsx b/packages/interface/src/components/QuickPreview/VideoControls.tsx new file mode 100644 index 000000000..3ea94c0a8 --- /dev/null +++ b/packages/interface/src/components/QuickPreview/VideoControls.tsx @@ -0,0 +1,288 @@ +import { + Play, + Pause, + SpeakerHigh, + SpeakerSlash, + ArrowsOut, + ClosedCaptioning, + MagnifyingGlassPlus, + MagnifyingGlassMinus, + ArrowCounterClockwise, + Gear, + Repeat, +} from "@phosphor-icons/react"; +import { motion, AnimatePresence } from "framer-motion"; +import type { File } from "@sd/ts-client"; +import { TimelineScrubber } from "./TimelineScrubber"; + +export interface VideoControlsState { + playing: boolean; + currentTime: number; + duration: number; + volume: number; + muted: boolean; + loop: boolean; + zoom: number; + subtitlesEnabled: boolean; + showSubtitleSettings: boolean; + seeking: boolean; + timelineHover: { percent: number; mouseX: number } | null; +} + +export interface VideoControlsCallbacks { + onTogglePlay: () => void; + onSeek: (e: React.MouseEvent) => void; + onTimelineHover: (e: React.MouseEvent) => void; + onTimelineLeave: () => void; + onSeekingStart: () => void; + onSeekingEnd: () => void; + onVolumeChange: (volume: number) => void; + onMuteToggle: () => void; + onLoopToggle: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onZoomReset: () => void; + onSubtitlesToggle: () => void; + onSubtitleSettingsToggle: () => void; + onFullscreenToggle: () => void; + onMouseMove: () => void; +} + +interface VideoControlsProps { + file: File; + state: VideoControlsState; + callbacks: VideoControlsCallbacks; + showControls: boolean; + sidebarWidth?: number; + inspectorWidth?: number; +} + +function formatTime(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + } + return `${mins}:${secs.toString().padStart(2, "0")}`; +} + +export function VideoControls({ + file, + state, + callbacks, + showControls, + sidebarWidth = 0, + inspectorWidth = 0, +}: VideoControlsProps) { + const hasSubs = file.sidecars?.some( + (s) => s.kind === "transcript" && s.variant === "srt", + ); + + return ( + + {showControls && ( + + {/* Timeline Scrubber Preview */} + {state.timelineHover && ( + + )} + + {/* Progress Bar with Thick Hover Area */} +
{ + callbacks.onSeekingStart(); + callbacks.onSeek(e); + }} + onMouseMove={(e) => { + if (state.seeking) { + callbacks.onSeek(e); + } else { + callbacks.onTimelineHover(e); + } + }} + onMouseEnter={callbacks.onTimelineHover} + onMouseUp={callbacks.onSeekingEnd} + onMouseLeave={callbacks.onTimelineLeave} + > +
+ {/* Progress */} +
+ + {/* Scrubber */} +
+
+
+
+
+ + {/* Controls Bar */} +
+ {/* Play/Pause */} + + + {/* Loop */} + + + {/* Time */} +
+ {formatTime(state.currentTime)} /{" "} + {formatTime(state.duration)} +
+ +
+ + {/* Subtitles Controls */} + {hasSubs && ( +
+ + {state.subtitlesEnabled && ( + + )} +
+ )} + + {/* Zoom Controls */} +
+ + + {state.zoom > 1 && ( + + )} +
+ + {/* Volume */} +
+ + + {/* Volume Slider */} +
+ + callbacks.onVolumeChange( + parseFloat(e.target.value), + ) + } + className="h-1 w-full cursor-pointer appearance-none rounded-full bg-white/20 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white" + /> +
+
+ + {/* Fullscreen */} + +
+ + )} + + ); +} diff --git a/packages/interface/src/components/QuickPreview/VideoPlayer.tsx b/packages/interface/src/components/QuickPreview/VideoPlayer.tsx index 2e81b0616..47c54b31c 100644 --- a/packages/interface/src/components/QuickPreview/VideoPlayer.tsx +++ b/packages/interface/src/components/QuickPreview/VideoPlayer.tsx @@ -1,38 +1,44 @@ -import { useState, useRef, useEffect } from 'react'; -import { Play, Pause, SpeakerHigh, SpeakerSlash, ArrowsOut, ClosedCaptioning, MagnifyingGlassPlus, MagnifyingGlassMinus, ArrowCounterClockwise, Gear, Repeat } from '@phosphor-icons/react'; -import { motion, AnimatePresence } from 'framer-motion'; -import type { File } from '@sd/ts-client'; -import { Subtitles, type SubtitleSettings } from './Subtitles'; -import { SubtitleSettingsMenu } from './SubtitleSettingsMenu'; -import { useZoomPan } from './useZoomPan'; -import { TimelineScrubber } from './TimelineScrubber'; +import { useState, useRef, useEffect, useCallback } from "react"; +import type { File } from "@sd/ts-client"; +import { Subtitles, type SubtitleSettings } from "./Subtitles"; +import { SubtitleSettingsMenu } from "./SubtitleSettingsMenu"; +import { useZoomPan } from "./useZoomPan"; +import type { + VideoControlsState, + VideoControlsCallbacks, +} from "./VideoControls"; interface VideoPlayerProps { src: string; file: File; onZoomChange?: (isZoomed: boolean) => void; + onControlsStateChange?: (state: VideoControlsState) => void; + onShowControlsChange?: (show: boolean) => void; + getCallbacks?: (callbacks: VideoControlsCallbacks) => void; } -function formatTime(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } - return `${mins}:${secs.toString().padStart(2, '0')}`; -} - -export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) { +export function VideoPlayer({ + src, + file, + onZoomChange, + onControlsStateChange, + onShowControlsChange, + getCallbacks, +}: VideoPlayerProps) { const videoRef = useRef(null); const containerRef = useRef(null); const videoContainerRef = useRef(null); const [playing, setPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); - const [volume, setVolume] = useState(1); - const [muted, setMuted] = useState(false); + const [volume, setVolume] = useState(() => { + const saved = localStorage.getItem("sd-video-volume"); + return saved ? parseFloat(saved) : 1; + }); + const [muted, setMuted] = useState(() => { + const saved = localStorage.getItem("sd-video-muted"); + return saved === "true"; + }); const [loop, setLoop] = useState(false); const [showControls, setShowControls] = useState(true); const [seeking, setSeeking] = useState(false); @@ -40,20 +46,114 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) { const [showSubtitleSettings, setShowSubtitleSettings] = useState(false); const [subtitleSettings, setSubtitleSettings] = useState({ fontSize: 1.5, - position: 'bottom', - backgroundOpacity: 0.9 + position: "bottom", + backgroundOpacity: 0.9, }); - const [timelineHover, setTimelineHover] = useState<{ percent: number; mouseX: number } | null>(null); - const hideControlsTimeout = useRef>(); - const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } = useZoomPan(videoContainerRef); + const [timelineHover, setTimelineHover] = useState<{ + percent: number; + mouseX: number; + } | null>(null); + const hideControlsTimeout = useRef(undefined); + const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } = + useZoomPan(videoContainerRef); + + // Expose controls state to parent + useEffect(() => { + onControlsStateChange?.({ + playing, + currentTime, + duration, + volume, + muted, + loop, + zoom, + subtitlesEnabled, + showSubtitleSettings, + seeking, + timelineHover, + }); + }, [ + playing, + currentTime, + duration, + volume, + muted, + loop, + zoom, + subtitlesEnabled, + showSubtitleSettings, + seeking, + timelineHover, + onControlsStateChange, + ]); + + // Expose showControls state to parent + useEffect(() => { + onShowControlsChange?.(showControls); + }, [showControls, onShowControlsChange]); // Notify parent of zoom state changes useEffect(() => { onZoomChange?.(isZoomed); }, [isZoomed, onZoomChange]); - // Show controls on mouse move, hide after 3s of inactivity - const handleMouseMove = () => { + const togglePlay = useCallback(() => { + if (!videoRef.current) return; + if (playing) { + videoRef.current.pause(); + } else { + videoRef.current.play(); + } + }, [playing]); + + const handleSeek = useCallback( + (e: React.MouseEvent) => { + if (!videoRef.current) return; + const rect = e.currentTarget.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + videoRef.current.currentTime = percent * duration; + }, + [duration], + ); + + const handleTimelineHover = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + setTimelineHover({ percent, mouseX: e.clientX }); + }, + [], + ); + + const toggleFullscreen = useCallback(() => { + if (!containerRef.current) return; + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + containerRef.current.requestFullscreen(); + } + }, []); + + const handleTimelineLeave = useCallback(() => { + setSeeking(false); + setTimelineHover(null); + }, []); + + const handleSeekingStart = useCallback(() => setSeeking(true), []); + const handleSeekingEnd = useCallback(() => setSeeking(false), []); + const handleMuteToggle = useCallback(() => setMuted((m) => !m), []); + const handleLoopToggle = useCallback(() => setLoop((l) => !l), []); + const handleSubtitlesToggle = useCallback( + () => setSubtitlesEnabled((s) => !s), + [], + ); + const handleSubtitleSettingsToggle = useCallback( + () => setShowSubtitleSettings((s) => !s), + [], + ); + + // Show controls on mouse move, hide after 1s of inactivity + const handleMouseMove = useCallback(() => { setShowControls(true); if (hideControlsTimeout.current) { clearTimeout(hideControlsTimeout.current); @@ -61,9 +161,48 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) { if (playing) { hideControlsTimeout.current = setTimeout(() => { setShowControls(false); - }, 3000); + }, 1000); } - }; + }, [playing]); + + // Provide callbacks to parent + useEffect(() => { + getCallbacks?.({ + onTogglePlay: togglePlay, + onSeek: handleSeek, + onTimelineHover: handleTimelineHover, + onTimelineLeave: handleTimelineLeave, + onSeekingStart: handleSeekingStart, + onSeekingEnd: handleSeekingEnd, + onVolumeChange: setVolume, + onMuteToggle: handleMuteToggle, + onLoopToggle: handleLoopToggle, + onZoomIn: zoomIn, + onZoomOut: zoomOut, + onZoomReset: reset, + onSubtitlesToggle: handleSubtitlesToggle, + onSubtitleSettingsToggle: handleSubtitleSettingsToggle, + onFullscreenToggle: toggleFullscreen, + onMouseMove: handleMouseMove, + }); + }, [ + togglePlay, + handleSeek, + handleTimelineHover, + handleTimelineLeave, + handleSeekingStart, + handleSeekingEnd, + handleMuteToggle, + handleLoopToggle, + handleSubtitlesToggle, + handleSubtitleSettingsToggle, + toggleFullscreen, + handleMouseMove, + zoomIn, + zoomOut, + reset, + getCallbacks, + ]); // Keyboard shortcuts useEffect(() => { @@ -71,61 +210,73 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) { if (!videoRef.current) return; switch (e.code) { - case 'Space': + case "Space": e.preventDefault(); togglePlay(); break; - case 'ArrowLeft': + case "ArrowLeft": e.preventDefault(); - videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 5); + videoRef.current.currentTime = Math.max( + 0, + videoRef.current.currentTime - 5, + ); break; - case 'ArrowRight': + case "ArrowRight": e.preventDefault(); videoRef.current.currentTime = Math.min( duration, - videoRef.current.currentTime + 5 + videoRef.current.currentTime + 5, ); break; - case 'ArrowUp': + case "ArrowUp": e.preventDefault(); setVolume((v) => Math.min(1, v + 0.1)); break; - case 'ArrowDown': + case "ArrowDown": e.preventDefault(); setVolume((v) => Math.max(0, v - 0.1)); break; - case 'KeyM': + case "KeyM": e.preventDefault(); - setMuted((m) => !m); + handleMuteToggle(); break; - case 'KeyF': + case "KeyF": e.preventDefault(); toggleFullscreen(); break; - case 'KeyC': + case "KeyC": e.preventDefault(); - setSubtitlesEnabled((s) => !s); + handleSubtitlesToggle(); break; - case 'KeyL': + case "KeyL": e.preventDefault(); - setLoop((l) => !l); + handleLoopToggle(); break; } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [duration, playing]); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + duration, + togglePlay, + toggleFullscreen, + handleMuteToggle, + handleSubtitlesToggle, + handleLoopToggle, + ]); - // Sync video element state + // Sync video element state and persist to localStorage useEffect(() => { if (!videoRef.current) return; videoRef.current.volume = volume; + localStorage.setItem("sd-video-volume", volume.toString()); }, [volume]); useEffect(() => { if (!videoRef.current) return; videoRef.current.muted = muted; + localStorage.setItem("sd-video-muted", muted.toString()); }, [muted]); useEffect(() => { @@ -133,45 +284,11 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) { videoRef.current.loop = loop; }, [loop]); - const togglePlay = () => { - if (!videoRef.current) return; - if (playing) { - videoRef.current.pause(); - } else { - videoRef.current.play(); - } - }; - - const handleSeek = (e: React.MouseEvent) => { - if (!videoRef.current) return; - const rect = e.currentTarget.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - videoRef.current.currentTime = percent * duration; - }; - - const handleTimelineHover = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - setTimelineHover({ percent, mouseX: e.clientX }); - }; - - const toggleFullscreen = () => { - if (!containerRef.current) return; - if (document.fullscreenElement) { - document.exitFullscreen(); - } else { - containerRef.current.requestFullscreen(); - } - }; - - const hasSubs = file.sidecars?.some(s => s.kind === 'transcript' && s.variant === 'srt'); - return (
playing && setShowControls(false)} > {/* Zoom level indicator */} {zoom > 1 && ( @@ -183,9 +300,12 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) { {/* Video container with zoom/pan */}
-
+
{/* Subtitles */} {subtitlesEnabled && ( - +
+ +
)} {/* Subtitle Settings Menu */} @@ -213,188 +346,6 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) { onSettingsChange={setSubtitleSettings} onClose={() => setShowSubtitleSettings(false)} /> - - {/* Controls Overlay */} - - {showControls && ( - - {/* Timeline Scrubber Preview */} - {timelineHover && ( - - )} - - {/* Progress Bar with Thick Hover Area */} -
{ - setSeeking(true); - handleSeek(e); - }} - onMouseMove={(e) => { - if (seeking) { - handleSeek(e); - } else { - handleTimelineHover(e); - } - }} - onMouseEnter={handleTimelineHover} - onMouseUp={() => setSeeking(false)} - onMouseLeave={() => { - setSeeking(false); - setTimelineHover(null); - }} - > -
- {/* Progress */} -
- - {/* Scrubber */} -
-
-
-
-
- - {/* Controls Bar */} -
- {/* Play/Pause */} - - - {/* Loop */} - - - {/* Time */} -
- {formatTime(currentTime)} / {formatTime(duration)} -
- -
- - {/* Subtitles Controls */} - {hasSubs && ( -
- - {subtitlesEnabled && ( - - )} -
- )} - - {/* Zoom Controls */} -
- - - {zoom > 1 && ( - - )} -
- - {/* Volume */} -
- - - {/* Volume Slider */} -
- setVolume(parseFloat(e.target.value))} - className="h-1 w-full cursor-pointer appearance-none rounded-full bg-white/20 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white" - /> -
-
- - {/* Fullscreen */} - -
- - )} -
); } diff --git a/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx b/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx index f3ffda2ce..cf91758b1 100644 --- a/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx +++ b/packages/interface/src/components/SpacesSidebar/DevicesGroup.tsx @@ -53,6 +53,18 @@ export function DevicesGroup({ onClick: async () => { await revokeDevice.mutateAsync({ device_id: device.id, + remove_from_library: false, // Keep device in library + }); + }, + variant: "default" as const, + }, + { + icon: Trash, + label: "Remove Device Completely", + onClick: async () => { + await revokeDevice.mutateAsync({ + device_id: device.id, + remove_from_library: true, // Remove from library too }); }, variant: "danger" as const, From ce1f52bde928fd00d65d6328d88e8fedf1e29c17 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 22 Dec 2025 07:50:35 -0800 Subject: [PATCH 11/12] Refactor job cancellation and pause/resume error handling - Replaced `eprintln!` with `tracing::warn!` for logging job cancellation, pause, and resume errors, improving logging consistency and integration with the tracing framework. - Updated the `useJobs` hook to handle job pause, resume, and cancel actions with improved error handling, ensuring better user feedback on job status. - Enhanced the UI components to reflect changes in job management, including better error reporting for failed actions. --- core/src/ops/jobs/control/cancel.rs | 4 +- core/src/ops/jobs/control/pause.rs | 4 +- core/src/ops/jobs/control/resume.rs | 4 +- .../components/JobManager/hooks/useJobs.ts | 27 +- .../QuickPreview/QuickPreviewFullscreen.tsx | 1 + .../src/components/QuickPreview/useZoomPan.ts | 74 +++-- .../src/components/SpacesSidebar/index.tsx | 258 +++++++++++++++-- packages/ts-client/src/generated/types.ts | 271 +++++++++--------- 8 files changed, 447 insertions(+), 196 deletions(-) diff --git a/core/src/ops/jobs/control/cancel.rs b/core/src/ops/jobs/control/cancel.rs index ac6fb63b2..cc4533080 100644 --- a/core/src/ops/jobs/control/cancel.rs +++ b/core/src/ops/jobs/control/cancel.rs @@ -10,6 +10,7 @@ use crate::{ use serde::{Deserialize, Serialize}; use specta::Type; use std::sync::Arc; +use tracing::warn; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -65,8 +66,7 @@ impl LibraryAction for JobCancelAction { success: true, }), Err(e) => { - // Return success=false instead of error for better UX - eprintln!("Failed to cancel job: {}", e); + warn!("Failed to cancel job {}: {}", self.input.job_id, e); Ok(JobCancelOutput { job_id: self.input.job_id, success: false, diff --git a/core/src/ops/jobs/control/pause.rs b/core/src/ops/jobs/control/pause.rs index b13220eda..37e2565b0 100644 --- a/core/src/ops/jobs/control/pause.rs +++ b/core/src/ops/jobs/control/pause.rs @@ -10,6 +10,7 @@ use crate::{ use serde::{Deserialize, Serialize}; use specta::Type; use std::sync::Arc; +use tracing::warn; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -65,8 +66,7 @@ impl LibraryAction for JobPauseAction { success: true, }), Err(e) => { - // Return success=false instead of error for better UX - eprintln!("Failed to pause job: {}", e); + warn!("Failed to pause job {}: {}", self.input.job_id, e); Ok(JobPauseOutput { job_id: self.input.job_id, success: false, diff --git a/core/src/ops/jobs/control/resume.rs b/core/src/ops/jobs/control/resume.rs index 08e8c7d8a..23fe5948c 100644 --- a/core/src/ops/jobs/control/resume.rs +++ b/core/src/ops/jobs/control/resume.rs @@ -10,6 +10,7 @@ use crate::{ use serde::{Deserialize, Serialize}; use specta::Type; use std::sync::Arc; +use tracing::warn; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, Type)] @@ -65,8 +66,7 @@ impl LibraryAction for JobResumeAction { success: true, }), Err(e) => { - // Return success=false instead of error for better UX - eprintln!("Failed to resume job: {}", e); + warn!("Failed to resume job {}: {}", self.input.job_id, e); Ok(JobResumeOutput { job_id: self.input.job_id, success: false, diff --git a/packages/interface/src/components/JobManager/hooks/useJobs.ts b/packages/interface/src/components/JobManager/hooks/useJobs.ts index 7bda47fd7..c527668dd 100644 --- a/packages/interface/src/components/JobManager/hooks/useJobs.ts +++ b/packages/interface/src/components/JobManager/hooks/useJobs.ts @@ -104,15 +104,36 @@ export function useJobs() { }, [client]); const pause = async (jobId: string) => { - await pauseMutation.mutateAsync({ job_id: jobId }); + try { + const result = await pauseMutation.mutateAsync({ job_id: jobId }); + if (!result.success) { + console.error("Failed to pause job:", jobId); + } + } catch (error) { + console.error("Failed to pause job:", error); + } }; const resume = async (jobId: string) => { - await resumeMutation.mutateAsync({ job_id: jobId }); + try { + const result = await resumeMutation.mutateAsync({ job_id: jobId }); + if (!result.success) { + console.error("Failed to resume job:", jobId); + } + } catch (error) { + console.error("Failed to resume job:", error); + } }; const cancel = async (jobId: string) => { - await cancelMutation.mutateAsync({ job_id: jobId }); + try { + const result = await cancelMutation.mutateAsync({ job_id: jobId }); + if (!result.success) { + console.error("Failed to cancel job:", jobId); + } + } catch (error) { + console.error("Failed to cancel job:", error); + } }; const runningCount = jobs.filter((j) => j.status === "running").length; diff --git a/packages/interface/src/components/QuickPreview/QuickPreviewFullscreen.tsx b/packages/interface/src/components/QuickPreview/QuickPreviewFullscreen.tsx index 2f7edf4c2..28ebf6327 100644 --- a/packages/interface/src/components/QuickPreview/QuickPreviewFullscreen.tsx +++ b/packages/interface/src/components/QuickPreview/QuickPreviewFullscreen.tsx @@ -187,6 +187,7 @@ export function QuickPreviewFullscreen({ style={{ paddingLeft: isZoomed ? 0 : sidebarWidth, paddingRight: isZoomed ? 0 : inspectorWidth, + transition: "padding 0.3s ease-out", }} > , - options: UseZoomPanOptions = {} + options: UseZoomPanOptions = {}, ) { - const { minZoom = 1, maxZoom = 5, zoomStep = 0.2 } = options; + const { minZoom = 1, maxZoom = 5, zoomStep = 0.1 } = options; const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); @@ -46,17 +46,25 @@ export function useZoomPan( const handleWheel = (e: WheelEvent) => { // Only zoom if not scrolling controls or other UI - if ((e.target as HTMLElement).closest('input, button, [role="slider"]')) { + if ( + (e.target as HTMLElement).closest( + 'input, button, [role="slider"]', + ) + ) { return; } e.preventDefault(); - const delta = -e.deltaY; - const zoomChange = delta > 0 ? zoomStep : -zoomStep; + // Scale the wheel delta proportionally (typical deltaY is ~100 per notch) + // Divide by 500 for responsive zoom: 100 deltaY = 0.2 zoom change + const zoomChange = -e.deltaY / 500; setZoom((z) => { - const newZoom = Math.max(minZoom, Math.min(maxZoom, z + zoomChange)); + const newZoom = Math.max( + minZoom, + Math.min(maxZoom, z + zoomChange), + ); // Reset pan when zooming back to 1x if (newZoom === 1) { setPan({ x: 0, y: 0 }); @@ -65,9 +73,9 @@ export function useZoomPan( }); }; - container.addEventListener('wheel', handleWheel, { passive: false }); - return () => container.removeEventListener('wheel', handleWheel); - }, [containerRef, minZoom, maxZoom, zoomStep]); + container.addEventListener("wheel", handleWheel, { passive: false }); + return () => container.removeEventListener("wheel", handleWheel); + }, [containerRef, minZoom, maxZoom]); // Pan with mouse drag (only when zoomed in) useEffect(() => { @@ -76,13 +84,17 @@ export function useZoomPan( const handleMouseDown = (e: MouseEvent) => { // Don't pan if clicking on controls - if ((e.target as HTMLElement).closest('button, input, [role="slider"]')) { + if ( + (e.target as HTMLElement).closest( + 'button, input, [role="slider"]', + ) + ) { return; } setIsDragging(true); setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y }); - container.style.cursor = 'grabbing'; + container.style.cursor = "grabbing"; }; const handleMouseMove = (e: MouseEvent) => { @@ -90,31 +102,31 @@ export function useZoomPan( setPan({ x: e.clientX - dragStart.x, - y: e.clientY - dragStart.y + y: e.clientY - dragStart.y, }); }; const handleMouseUp = () => { setIsDragging(false); if (zoom > 1) { - container.style.cursor = 'grab'; + container.style.cursor = "grab"; } else { - container.style.cursor = 'default'; + container.style.cursor = "default"; } }; - container.addEventListener('mousedown', handleMouseDown); - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); + container.addEventListener("mousedown", handleMouseDown); + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); // Set cursor - container.style.cursor = zoom > 1 ? 'grab' : 'default'; + container.style.cursor = zoom > 1 ? "grab" : "default"; return () => { - container.removeEventListener('mousedown', handleMouseDown); - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - container.style.cursor = 'default'; + container.removeEventListener("mousedown", handleMouseDown); + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + container.style.cursor = "default"; }; }, [containerRef, zoom, pan, isDragging, dragStart]); @@ -122,24 +134,24 @@ export function useZoomPan( useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Don't interfere with inputs - if ((e.target as HTMLElement).tagName === 'INPUT') { + if ((e.target as HTMLElement).tagName === "INPUT") { return; } - if (e.key === '=' || e.key === '+') { + if (e.key === "=" || e.key === "+") { e.preventDefault(); zoomIn(); - } else if (e.key === '-' || e.key === '_') { + } else if (e.key === "-" || e.key === "_") { e.preventDefault(); zoomOut(); - } else if (e.key === '0') { + } else if (e.key === "0") { e.preventDefault(); reset(); } }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); }, [zoomIn, zoomOut, reset]); return { @@ -151,7 +163,7 @@ export function useZoomPan( isZoomed: zoom > 1, transform: { transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, - transition: isDragging ? 'none' : 'transform 0.1s ease-out' - } + transition: isDragging ? "none" : "transform 0.05s ease-out", + }, }; } diff --git a/packages/interface/src/components/SpacesSidebar/index.tsx b/packages/interface/src/components/SpacesSidebar/index.tsx index ec502d181..8cd0f4161 100644 --- a/packages/interface/src/components/SpacesSidebar/index.tsx +++ b/packages/interface/src/components/SpacesSidebar/index.tsx @@ -1,7 +1,8 @@ import { useState, useEffect } from "react"; -import { GearSix, Palette } from "@phosphor-icons/react"; +import { GearSix, Palette, ArrowsClockwise, ListBullets, CircleNotch, ArrowsOut, FunnelSimple } from "@phosphor-icons/react"; import { useSidebarStore, useLibraryMutation } from "@sd/ts-client"; import type { SpaceGroup as SpaceGroupType, SpaceItem as SpaceItemType } from "@sd/ts-client"; +import { TopBarButton, Popover, usePopover } from "@sd/ui"; import { useSpaces, useSpaceLayout } from "./hooks/useSpaces"; import { SpaceSwitcher } from "./SpaceSwitcher"; import { SpaceGroup } from "./SpaceGroup"; @@ -11,12 +12,19 @@ import { SpaceCustomizationPanel } from "./SpaceCustomizationPanel"; import { useSpacedriveClient } from "../../context"; import { useLibraries } from "../../hooks/useLibraries"; import { usePlatform } from "../../platform"; -import { JobManagerPopover } from "../JobManager/JobManagerPopover"; -import { SyncMonitorPopover } from "../SyncMonitor"; +import { useJobs } from "../JobManager/hooks/useJobs"; +import { useSyncCount } from "../SyncMonitor/hooks/useSyncCount"; +import { useSyncMonitor } from "../SyncMonitor/hooks/useSyncMonitor"; +import { PeerList } from "../SyncMonitor/components/PeerList"; +import { ActivityFeed } from "../SyncMonitor/components/ActivityFeed"; +import { JobList } from "../JobManager/components/JobList"; +import { motion } from "framer-motion"; +import { CARD_HEIGHT } from "../JobManager/types"; import clsx from "clsx"; import { useDroppable, useDndContext } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { useNavigate } from "react-router-dom"; // Wrapper that adds a space-level drop zone before each group and makes it sortable function SpaceGroupWithDropZone({ @@ -86,6 +94,205 @@ function SpaceGroupWithDropZone({ ); } +// Sync Monitor Button with Popover +function SyncButton() { + const popover = usePopover(); + const navigate = useNavigate(); + const [showActivityFeed, setShowActivityFeed] = useState(false); + const { onlinePeerCount, isSyncing } = useSyncCount(); + const sync = useSyncMonitor(); + + useEffect(() => { + if (popover.open) { + setShowActivityFeed(false); + } + }, [popover.open]); + + const getStateColor = (state: string) => { + switch (state) { + case "Ready": + return "bg-green-500"; + case "Backfilling": + return "bg-yellow-500"; + case "CatchingUp": + return "bg-accent"; + case "Uninitialized": + return "bg-ink-faint"; + case "Paused": + return "bg-ink-dull"; + default: + return "bg-ink-faint"; + } + }; + + return ( + + isSyncing ? ( + + ) : ( + + ) + } + title="Sync Monitor" + /> + } + side="top" + align="end" + sideOffset={8} + className="w-[380px] max-h-[520px] z-50 !p-0 !bg-app !rounded-xl" + > +
+

Sync Monitor

+ +
+ {onlinePeerCount > 0 && ( + + {onlinePeerCount} {onlinePeerCount === 1 ? "peer" : "peers"} online + + )} + + navigate("/sync")} + title="Open full sync monitor" + /> + + setShowActivityFeed(!showActivityFeed)} + title={showActivityFeed ? "Show peers" : "Show activity feed"} + /> +
+
+ + {popover.open && ( + <> +
+
+
+ {sync.currentState} +
+
+ + {showActivityFeed ? ( + + ) : ( + + )} + + + )} + + ); +} + +// Jobs Button with Popover +function JobsButton({ + activeJobCount, + hasRunningJobs, + jobs, + pause, + resume, + cancel, + navigate +}: { + activeJobCount: number; + hasRunningJobs: boolean; + jobs: any[]; + pause: (jobId: string) => Promise; + resume: (jobId: string) => Promise; + cancel: (jobId: string) => Promise; + navigate: any; +}) { + const popover = usePopover(); + const [showOnlyRunning, setShowOnlyRunning] = useState(true); + + useEffect(() => { + if (popover.open) { + setShowOnlyRunning(true); + } + }, [popover.open]); + + const filteredJobs = showOnlyRunning + ? jobs.filter((job) => job.status === "running" || job.status === "paused") + : jobs; + + return ( + + hasRunningJobs ? ( + + ) : ( + + ) + } + title="Job Manager" + /> + } + side="top" + align="end" + sideOffset={8} + className="w-[360px] max-h-[480px] z-50 !p-0 !bg-app !rounded-xl" + > +
+

Job Manager

+ +
+ {activeJobCount > 0 && ( + {activeJobCount} active + )} + + navigate("/jobs")} + title="Open full jobs screen" + /> + + setShowOnlyRunning(!showOnlyRunning)} + title={showOnlyRunning ? "Show all jobs" : "Show only active jobs"} + /> +
+
+ + {popover.open && ( + + + + )} +
+ ); +} + interface SpacesSidebarProps { isPreviewActive?: boolean; } @@ -93,12 +300,17 @@ interface SpacesSidebarProps { export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) { const client = useSpacedriveClient(); const platform = usePlatform(); + const navigate = useNavigate(); const { data: libraries } = useLibraries(); const [currentLibraryId, setCurrentLibraryId] = useState( () => client.getCurrentLibraryId(), ); const [customizePanelOpen, setCustomizePanelOpen] = useState(false); + // Get sync and job status for icons + const { onlinePeerCount, isSyncing } = useSyncCount(); + const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel } = useJobs(); + const { currentSpaceId, setCurrentSpace } = useSidebarStore(); const { data: spacesData } = useSpaces(); const spaces = spacesData?.spaces; @@ -209,20 +421,25 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
{/* Sync Monitor, Job Manager, Customize & Settings (pinned to bottom) */} -
- - - - + />
diff --git a/packages/ts-client/src/generated/types.ts b/packages/ts-client/src/generated/types.ts index bfd8d60f9..a7281bac0 100644 --- a/packages/ts-client/src/generated/types.ts +++ b/packages/ts-client/src/generated/types.ts @@ -419,7 +419,14 @@ export type DeviceInfo = { id: string; name: string; os: string; hardware_model: */ export type DeviceMetricsSnapshot = { device_id: string; device_name: string; entries_received: number; last_seen: string; is_online: boolean }; -export type DeviceRevokeInput = { device_id: string }; +export type DeviceRevokeInput = { device_id: string; +/** + * Whether to also remove the device from all library databases + * + * If false (default), only unpairs from network but keeps device history in libraries. + * If true, completely removes device from libraries (deletes all records). + */ +remove_from_library?: boolean }; export type DeviceRevokeOutput = { revoked: boolean }; @@ -4090,215 +4097,215 @@ success: boolean }; // ===== API Type Unions ===== export type CoreAction = - { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput } - | { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput } - | { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput } + { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput } | { type: 'models.whisper.delete'; input: DeleteWhisperModelInput; output: DeleteWhisperModelOutput } | { type: 'models.whisper.download'; input: DownloadWhisperModelInput; output: DownloadWhisperModelOutput } - | { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput } - | { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput } - | { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput } - | { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput } - | { type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput } + | { type: 'core.ephemeral_reset'; input: EphemeralCacheResetInput; output: EphemeralCacheResetOutput } + | { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput } | { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput } | { type: 'network.start'; input: NetworkStartInput; output: NetworkStartOutput } - | { type: 'core.reset'; input: ResetDataInput; output: ResetDataOutput } - | { type: 'core.ephemeral_reset'; input: EphemeralCacheResetInput; output: EphemeralCacheResetOutput } + | { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput } + | { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput } + | { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput } + | { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput } | { type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput } + | { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput } + | { type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput } + | { type: 'core.reset'; input: ResetDataInput; output: ResetDataOutput } ; export type LibraryAction = - { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput } - | { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput } - | { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput } - | { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt } - | { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput } - | { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput } - | { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput } - | { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput } - | { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput } - | { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput } - | { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput } - | { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput } - | { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput } - | { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput } - | { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput } + { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput } + | { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput } + | { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput } + | { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput } + | { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput } | { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput } - | { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput } - | { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput } - | { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput } - | { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput } - | { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput } | { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput } - | { type: 'files.copy'; input: FileCopyInput; output: JobReceipt } - | { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput } - | { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput } - | { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput } - | { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput } + | { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput } + | { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput } | { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput } - | { type: 'indexing.start'; input: IndexInput; output: JobReceipt } + | { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput } + | { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput } + | { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput } | { type: 'spaces.reorder_items'; input: ReorderItemsInput; output: ReorderOutput } | { type: 'spaces.reorder_groups'; input: ReorderGroupsInput; output: ReorderOutput } + | { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput } + | { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput } + | { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput } + | { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput } + | { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput } + | { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput } + | { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput } | { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput } - | { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput } - | { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput } - | { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput } - | { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput } - | { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput } - | { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt } - | { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput } - | { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput } + | { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput } + | { type: 'files.copy'; input: FileCopyInput; output: JobReceipt } | { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput } - | { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput } + | { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput } + | { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput } + | { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput } + | { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput } + | { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput } + | { type: 'indexing.start'; input: IndexInput; output: JobReceipt } + | { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput } | { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput } + | { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput } + | { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput } + | { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput } + | { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput } + | { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput } + | { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt } + | { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput } + | { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput } + | { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt } ; export type CoreQuery = - { type: 'core.status'; input: Empty; output: CoreStatus } + { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput } | { type: 'jobs.remote.all_devices'; input: RemoteJobsAllDevicesInput; output: RemoteJobsAllDevicesOutput } | { type: 'jobs.remote.for_device'; input: RemoteJobsForDeviceInput; output: RemoteJobsForDeviceOutput } | { type: 'core.events.list'; input: ListEventsInput; output: ListEventsOutput } | { type: 'core.ephemeral_status'; input: EphemeralCacheStatusInput; output: EphemeralCacheStatus } | { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput } - | { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput } - | { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput } + | { type: 'core.status'; input: Empty; output: CoreStatus } | { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] } - | { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput } | { type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus } + | { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput } + | { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput } ; export type LibraryQuery = { type: 'test.ping'; input: PingInput; output: PingOutput } - | { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput } - | { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput } - | { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput } - | { type: 'jobs.list'; input: JobListInput; output: JobListOutput } - | { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput } - | { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput } - | { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput } | { type: 'files.by_path'; input: FileByPathQuery; output: File } + | { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput } + | { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout } + | { type: 'files.content_kind_stats'; input: ContentKindStatsInput; output: ContentKindStatsOutput } + | { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput } | { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput } - | { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput } | { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput } + | { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput } + | { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput } + | { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput } + | { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput } + | { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput } + | { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput } + | { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput } + | { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput } + | { type: 'devices.list'; input: ListLibraryDevicesInput; output: [Device] } + | { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput } + | { type: 'jobs.list'; input: JobListInput; output: JobListOutput } | { type: 'libraries.info'; input: LibraryInfoQueryInput; output: Library } | { type: 'files.by_id'; input: FileByIdQuery; output: File } - | { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput } - | { type: 'devices.list'; input: ListLibraryDevicesInput; output: [Device] } - | { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput } - | { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput } + | { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput } | { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput } - | { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput } - | { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput } - | { type: 'files.content_kind_stats'; input: ContentKindStatsInput; output: ContentKindStatsOutput } - | { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout } - | { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput } + | { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput } ; // ===== Wire Method Mappings ===== export const WIRE_METHODS = { coreActions: { - 'libraries.open': 'action:libraries.open.input', - 'network.pair.cancel': 'action:network.pair.cancel.input', - 'network.device.revoke': 'action:network.device.revoke.input', + 'libraries.create': 'action:libraries.create.input', 'models.whisper.delete': 'action:models.whisper.delete.input', 'models.whisper.download': 'action:models.whisper.download.input', - 'libraries.create': 'action:libraries.create.input', - 'network.pair.generate': 'action:network.pair.generate.input', - 'network.pair.join': 'action:network.pair.join.input', - 'network.spacedrop.send': 'action:network.spacedrop.send.input', - 'libraries.delete': 'action:libraries.delete.input', + 'core.ephemeral_reset': 'action:core.ephemeral_reset.input', + 'network.pair.cancel': 'action:network.pair.cancel.input', 'network.sync_setup': 'action:network.sync_setup.input', 'network.start': 'action:network.start.input', - 'core.reset': 'action:core.reset.input', - 'core.ephemeral_reset': 'action:core.ephemeral_reset.input', + 'network.pair.generate': 'action:network.pair.generate.input', + 'libraries.open': 'action:libraries.open.input', + 'network.spacedrop.send': 'action:network.spacedrop.send.input', + 'network.device.revoke': 'action:network.device.revoke.input', 'network.stop': 'action:network.stop.input', + 'network.pair.join': 'action:network.pair.join.input', + 'libraries.delete': 'action:libraries.delete.input', + 'core.reset': 'action:core.reset.input', }, libraryActions: { + 'spaces.delete_group': 'action:spaces.delete_group.input', + 'jobs.resume': 'action:jobs.resume.input', + 'volumes.refresh': 'action:volumes.refresh.input', 'volumes.track': 'action:volumes.track.input', - 'locations.enable_indexing': 'action:locations.enable_indexing.input', - 'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input', - 'media.thumbnail': 'action:media.thumbnail.input', - 'media.splat.generate': 'action:media.splat.generate.input', - 'spaces.delete_item': 'action:spaces.delete_item.input', - 'indexing.verify': 'action:indexing.verify.input', - 'volumes.index': 'action:volumes.index.input', - 'volumes.remove_cloud': 'action:volumes.remove_cloud.input', - 'volumes.add_cloud': 'action:volumes.add_cloud.input', - 'spaces.update_group': 'action:spaces.update_group.input', - 'tags.create': 'action:tags.create.input', - 'locations.remove': 'action:locations.remove.input', - 'libraries.rename': 'action:libraries.rename.input', - 'spaces.add_group': 'action:spaces.add_group.input', + 'spaces.update': 'action:spaces.update.input', 'volumes.speed_test': 'action:volumes.speed_test.input', - 'libraries.export': 'action:libraries.export.input', - 'jobs.cancel': 'action:jobs.cancel.input', - 'locations.import': 'action:locations.import.input', - 'locations.add': 'action:locations.add.input', - 'locations.update': 'action:locations.update.input', 'volumes.untrack': 'action:volumes.untrack.input', - 'files.copy': 'action:files.copy.input', - 'tags.apply': 'action:tags.apply.input', - 'media.ocr.extract': 'action:media.ocr.extract.input', - 'spaces.add_item': 'action:spaces.add_item.input', - 'spaces.create': 'action:spaces.create.input', + 'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input', + 'locations.update': 'action:locations.update.input', 'locations.triggerJob': 'action:locations.triggerJob.input', - 'indexing.start': 'action:indexing.start.input', + 'spaces.delete': 'action:spaces.delete.input', + 'spaces.add_item': 'action:spaces.add_item.input', + 'libraries.export': 'action:libraries.export.input', 'spaces.reorder_items': 'action:spaces.reorder_items.input', 'spaces.reorder_groups': 'action:spaces.reorder_groups.input', + 'volumes.add_cloud': 'action:volumes.add_cloud.input', + 'spaces.update_group': 'action:spaces.update_group.input', + 'locations.enable_indexing': 'action:locations.enable_indexing.input', + 'locations.add': 'action:locations.add.input', + 'volumes.remove_cloud': 'action:volumes.remove_cloud.input', + 'spaces.create': 'action:spaces.create.input', + 'locations.remove': 'action:locations.remove.input', 'jobs.pause': 'action:jobs.pause.input', - 'media.proxy.generate': 'action:media.proxy.generate.input', - 'spaces.update': 'action:spaces.update.input', - 'spaces.delete_group': 'action:spaces.delete_group.input', - 'locations.export': 'action:locations.export.input', - 'spaces.delete': 'action:spaces.delete.input', - 'files.delete': 'action:files.delete.input', - 'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input', - 'volumes.refresh': 'action:volumes.refresh.input', + 'media.splat.generate': 'action:media.splat.generate.input', + 'files.copy': 'action:files.copy.input', 'media.speech.transcribe': 'action:media.speech.transcribe.input', - 'jobs.resume': 'action:jobs.resume.input', + 'indexing.verify': 'action:indexing.verify.input', + 'spaces.add_group': 'action:spaces.add_group.input', + 'tags.apply': 'action:tags.apply.input', + 'locations.import': 'action:locations.import.input', + 'media.ocr.extract': 'action:media.ocr.extract.input', + 'indexing.start': 'action:indexing.start.input', + 'media.proxy.generate': 'action:media.proxy.generate.input', 'locations.rescan': 'action:locations.rescan.input', + 'libraries.rename': 'action:libraries.rename.input', + 'locations.export': 'action:locations.export.input', + 'spaces.delete_item': 'action:spaces.delete_item.input', + 'tags.create': 'action:tags.create.input', + 'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input', + 'media.thumbnail': 'action:media.thumbnail.input', + 'jobs.cancel': 'action:jobs.cancel.input', + 'volumes.index': 'action:volumes.index.input', + 'files.delete': 'action:files.delete.input', }, coreQueries: { - 'core.status': 'query:core.status', + 'network.devices.list': 'query:network.devices.list', 'jobs.remote.all_devices': 'query:jobs.remote.all_devices', 'jobs.remote.for_device': 'query:jobs.remote.for_device', 'core.events.list': 'query:core.events.list', 'core.ephemeral_status': 'query:core.ephemeral_status', 'network.sync_setup.discover': 'query:network.sync_setup.discover', - 'network.devices.list': 'query:network.devices.list', - 'network.pair.status': 'query:network.pair.status', + 'core.status': 'query:core.status', 'libraries.list': 'query:libraries.list', - 'models.whisper.list': 'query:models.whisper.list', 'network.status': 'query:network.status', + 'models.whisper.list': 'query:models.whisper.list', + 'network.pair.status': 'query:network.pair.status', }, libraryQueries: { 'test.ping': 'query:test.ping', - 'spaces.get': 'query:spaces.get', - 'sync.activity': 'query:sync.activity', - 'sync.eventLog': 'query:sync.eventLog', - 'jobs.list': 'query:jobs.list', - 'files.directory_listing': 'query:files.directory_listing', - 'spaces.list': 'query:spaces.list', - 'locations.suggested': 'query:locations.suggested', 'files.by_path': 'query:files.by_path', + 'files.unique_to_location': 'query:files.unique_to_location', + 'spaces.get_layout': 'query:spaces.get_layout', + 'files.content_kind_stats': 'query:files.content_kind_stats', + 'sync.metrics': 'query:sync.metrics', 'jobs.info': 'query:jobs.info', - 'volumes.list': 'query:volumes.list', 'files.media_listing': 'query:files.media_listing', + 'files.directory_listing': 'query:files.directory_listing', + 'locations.validate_path': 'query:locations.validate_path', + 'locations.list': 'query:locations.list', + 'sync.activity': 'query:sync.activity', + 'search.files': 'query:search.files', + 'jobs.active': 'query:jobs.active', + 'sync.eventLog': 'query:sync.eventLog', + 'volumes.list': 'query:volumes.list', + 'devices.list': 'query:devices.list', + 'spaces.list': 'query:spaces.list', + 'jobs.list': 'query:jobs.list', 'libraries.info': 'query:libraries.info', 'files.by_id': 'query:files.by_id', - 'files.unique_to_location': 'query:files.unique_to_location', - 'devices.list': 'query:devices.list', - 'search.files': 'query:search.files', - 'locations.validate_path': 'query:locations.validate_path', + 'spaces.get': 'query:spaces.get', 'tags.search': 'query:tags.search', - 'jobs.active': 'query:jobs.active', - 'sync.metrics': 'query:sync.metrics', - 'files.content_kind_stats': 'query:files.content_kind_stats', - 'spaces.get_layout': 'query:spaces.get_layout', - 'locations.list': 'query:locations.list', + 'locations.suggested': 'query:locations.suggested', }, } as const; From e0989a01a5d08a8fd25a22cec7cb76cd3e189a0c Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Tue, 23 Dec 2025 00:34:53 -0800 Subject: [PATCH 12/12] Enhance PathBar component with editing functionality - Introduced editing mode in the PathBar component, allowing users to modify paths directly. - Added state management for editing, including handling input changes and keyboard events for submission and cancellation. - Updated the rendering logic to accommodate editing input, improving user interaction. - Refactored device icon retrieval to streamline the process and ensure correct icon display based on device state. - Adjusted width calculations for different states, enhancing the visual responsiveness of the PathBar. --- .../Explorer/components/PathBar.tsx | 134 +++++++++++++++--- .../src/components/SpacesSidebar/index.tsx | 38 ++--- 2 files changed, 138 insertions(+), 34 deletions(-) diff --git a/packages/interface/src/components/Explorer/components/PathBar.tsx b/packages/interface/src/components/Explorer/components/PathBar.tsx index fc00bb2d4..18481b518 100644 --- a/packages/interface/src/components/Explorer/components/PathBar.tsx +++ b/packages/interface/src/components/Explorer/components/PathBar.tsx @@ -10,7 +10,7 @@ import { RadioButtonIcon, } from "@phosphor-icons/react"; import type { SdPath, LibraryDeviceInfo } from "@sd/ts-client"; -import { getDeviceIconBySlug, useLibraryMutation } from "@sd/ts-client"; +import { getDeviceIcon, useLibraryMutation } from "@sd/ts-client"; import { sdPathToUri } from "../utils"; import LaptopIcon from "@sd/assets/icons/Laptop.png"; import { useNormalizedQuery } from "@sd/ts-client"; @@ -250,6 +250,9 @@ function IndexIndicator({ path }: { path: SdPath }) { export function PathBar({ path, devices, onNavigate }: PathBarProps) { const [isExpanded, setIsExpanded] = useState(false); const [isShiftHeld, setIsShiftHeld] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(""); + const [editingAsUri, setEditingAsUri] = useState(false); const { navigateToView } = useExplorer(); const uri = sdPathToUri(path); const currentDir = getCurrentDirectoryName(path); @@ -264,7 +267,7 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) { (d) => d.slug === deviceSlug, ); return { - icon: getDeviceIconBySlug(deviceSlug, devices), + icon: device ? getDeviceIcon(device) : LaptopIcon, device, }; } @@ -278,6 +281,68 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) { } }; + const enterEditMode = (initialValue: string, asUri: boolean) => { + setIsEditing(true); + setEditValue(initialValue); + setEditingAsUri(asUri); + }; + + const exitEditMode = () => { + setIsEditing(false); + setEditValue(""); + setEditingAsUri(false); + }; + + const handleContainerClick = (e: React.MouseEvent) => { + // Only enter edit mode if clicking the container itself, not buttons/segments + if (e.target === e.currentTarget || (e.target as HTMLElement).tagName === "INPUT") { + const isUriMode = showUri; + const valueToEdit = isUriMode ? uri : ("Physical" in path ? path.Physical.path : uri); + enterEditMode(valueToEdit, isUriMode); + } + }; + + const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + submitEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + exitEditMode(); + } + }; + + const submitEdit = () => { + const trimmed = editValue.trim(); + if (!trimmed) { + exitEditMode(); + return; + } + + try { + if (editingAsUri) { + // Try to parse as SdPath JSON + const parsed = JSON.parse(trimmed) as SdPath; + onNavigate(parsed); + } else { + // Parse as file path string + if ("Physical" in path) { + const newPath: SdPath = { + Physical: { + device_slug: path.Physical.device_slug, + path: trimmed.startsWith("/") ? trimmed : `/${trimmed}`, + }, + }; + onNavigate(newPath); + } + } + } catch (error) { + console.error("Failed to parse path:", error); + } + + exitEditMode(); + }; + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Shift") setIsShiftHeld(true); @@ -297,7 +362,7 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) { const showUri = isExpanded && isShiftHeld; - // Calculate widths for three states + // Calculate widths for different states const collapsedWidth = currentDir.length * 8.5 + 70; const breadcrumbsWidth = Math.min( segments.reduce((sum, seg) => sum + seg.name.length * 6.5, 0) + @@ -306,29 +371,37 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) { 600, ); const uriWidth = Math.min(uri.length * 7 + 70, 600); + const editWidth = Math.max(200, Math.min(editValue.length * 7 + 70, 600)); - const currentWidth = !isExpanded - ? collapsedWidth - : showUri - ? uriWidth - : breadcrumbsWidth; + const currentWidth = isEditing + ? editWidth + : !isExpanded + ? collapsedWidth + : showUri + ? uriWidth + : breadcrumbsWidth; return (
setIsExpanded(true)} - onMouseLeave={() => setIsExpanded(false)} + onMouseEnter={() => !isEditing && setIsExpanded(true)} + onMouseLeave={() => !isEditing && setIsExpanded(false)} + onClick={handleContainerClick} className={clsx( "flex items-center gap-1.5 h-8 px-3 rounded-full", "backdrop-blur-xl border border-sidebar-line/30", "bg-sidebar-box/20 transition-colors", "focus-within:bg-sidebar-box/30 focus-within:border-sidebar-line/40", + !isEditing && "cursor-text", )} > - {showUri ? ( + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={handleEditKeyDown} + onBlur={exitEditMode} + autoFocus + className={clsx( + "bg-transparent border-0 outline-none ring-0 flex-1 min-w-0", + "text-xs font-medium text-sidebar-ink", + "placeholder:text-sidebar-inkFaint", + "focus:ring-0 focus:outline-none", + editingAsUri && "font-mono", + )} + placeholder={editingAsUri ? "Enter SdPath JSON..." : "Enter path..."} + /> + ) : showUri ? ( - {!isLast && } + {!isLast && ( + + )}
); })} diff --git a/packages/interface/src/components/SpacesSidebar/index.tsx b/packages/interface/src/components/SpacesSidebar/index.tsx index 8cd0f4161..b0c506b8b 100644 --- a/packages/interface/src/components/SpacesSidebar/index.tsx +++ b/packages/interface/src/components/SpacesSidebar/index.tsx @@ -141,7 +141,7 @@ function SyncButton() { /> } side="top" - align="end" + align="start" sideOffset={8} className="w-[380px] max-h-[520px] z-50 !p-0 !bg-app !rounded-xl" > @@ -247,7 +247,7 @@ function JobsButton({ /> } side="top" - align="end" + align="start" sideOffset={8} className="w-[360px] max-h-[480px] z-50 !p-0 !bg-app !rounded-xl" > @@ -421,22 +421,24 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
{/* Sync Monitor, Job Manager, Customize & Settings (pinned to bottom) */} -
- - - setCustomizePanelOpen(true)} - /> +
+
+ + + setCustomizePanelOpen(true)} + /> +