mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 05:45:01 -04:00
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.
This commit is contained in:
@@ -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 = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Spacedrive/Info.plist; sourceTree = "<group>"; };
|
||||
1F01297183477A50BDE411D2 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Spacedrive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
25178F2D681F4D65F10117CC /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Spacedrive/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
AA40588139FF2A1626CB0A54 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Spacedrive/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
F11748442D0722820044C1D9 /* Spacedrive-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Spacedrive-Bridging-Header.h"; path = "Spacedrive/Spacedrive-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
F9029D97821ED7825EDEDD6C /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Spacedrive/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
@@ -62,29 +70,11 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
8D2433440EF620DAB0150F1F /* libPods-Spacedrive.a */,
|
||||
48CBEA58F7B27690483159A9 /* libPods-Spacedrive.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
42798537B08B5D76DFB457E7 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
22C87D25B25EC9845FB7C7F8 /* Pods-Spacedrive.debug.xcconfig */,
|
||||
6BF866963457A8D6655775AD /* Pods-Spacedrive.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4FB1CBF77585F74645A1185E /* Spacedrive */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
25178F2D681F4D65F10117CC /* ExpoModulesProvider.swift */,
|
||||
);
|
||||
name = Spacedrive;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
@@ -115,12 +105,12 @@
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B72BBC0A0CC1879D53F63DAB /* ExpoModulesProviders */ = {
|
||||
A6A0A3E08EC06A5293CBB19F /* Spacedrive */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4FB1CBF77585F74645A1185E /* Spacedrive */,
|
||||
F9029D97821ED7825EDEDD6C /* ExpoModulesProvider.swift */,
|
||||
);
|
||||
name = ExpoModulesProviders;
|
||||
name = Spacedrive;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB2F792B24A3F905000567C9 /* Supporting */ = {
|
||||
@@ -132,6 +122,16 @@
|
||||
path = Spacedrive/Supporting;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E0DC8B892BBF51D498C04E89 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4D70C582F0FE4A3831CB86B0 /* Pods-Spacedrive.debug.xcconfig */,
|
||||
74DA361BB374DD6494CB5246 /* Pods-Spacedrive.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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;
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
//! 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::<Vec<_>>()
|
||||
.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");
|
||||
}
|
||||
@@ -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<string, string> = {};
|
||||
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 <EmptyView />;
|
||||
@@ -133,7 +82,7 @@ export function ExplorerView() {
|
||||
<PathBar
|
||||
path={currentPath}
|
||||
devices={devices}
|
||||
onNavigate={setCurrentPath}
|
||||
onNavigate={navigateToPath}
|
||||
/>
|
||||
)}
|
||||
{currentView && (
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
export interface VirtualView {
|
||||
view: string;
|
||||
id?: string;
|
||||
params?: Record<string, string>;
|
||||
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<ViewSettings> }
|
||||
| { 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<ViewSettings>;
|
||||
};
|
||||
|
||||
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<string, string> = {};
|
||||
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<string, string>;
|
||||
} | null;
|
||||
|
||||
navigateToPath: (path: SdPath) => void;
|
||||
navigateToView: (
|
||||
view: string,
|
||||
id?: string,
|
||||
params?: Record<string, string>,
|
||||
) => 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<ViewSettings>) => 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<string, any>;
|
||||
devices: Map<string, Device>;
|
||||
|
||||
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<ExplorerState | null>(null);
|
||||
const ExplorerContext = createContext<ExplorerContextValue | null>(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<SdPath | null>(null);
|
||||
const [currentView, setCurrentView] = useState<VirtualView | null>(null);
|
||||
const [history, setHistory] = useState<NavigationEntry[]>([]);
|
||||
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<ViewSettings>({
|
||||
gridSize: 120,
|
||||
gapSize: 16,
|
||||
showFileSize: true,
|
||||
columnWidth: 256,
|
||||
foldersFirst: false,
|
||||
});
|
||||
const [sidebarVisible, setSidebarVisible] = useState(true);
|
||||
const [inspectorVisible, setInspectorVisible] = useState(true);
|
||||
const [quickPreviewFileId, setQuickPreviewFileId] = useState<string | null>(
|
||||
null,
|
||||
const [uiState, uiDispatch] = useReducer(uiReducer, initialUIState);
|
||||
const [currentFiles, setCurrentFiles] = useReducer(
|
||||
(_: File[], files: File[]) => files,
|
||||
[] as File[],
|
||||
);
|
||||
const [currentFiles, setCurrentFiles] = useState<File[]>([]);
|
||||
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<ViewSettings>) => {
|
||||
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<ListLibraryDevicesInput, any[]>({
|
||||
const devicesQuery = useNormalizedQuery<ListLibraryDevicesInput, Device[]>({
|
||||
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<string, string>) => {
|
||||
// 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<ViewSettings>) => {
|
||||
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<ExplorerContextValue>(
|
||||
() => ({
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<d3.Selection<
|
||||
SVGGElement,
|
||||
@@ -168,10 +168,10 @@ export function SizeView() {
|
||||
|
||||
useEffect(() => {
|
||||
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) => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user