From d52e89768de03c2c5eab4da2fa83a08c69f617ff Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Mon, 22 Dec 2025 05:37:08 -0800 Subject: [PATCH] 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); } },