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:
Jamie Pine
2025-12-22 05:37:08 -08:00
parent 1832d929ea
commit d52e89768d
16 changed files with 710 additions and 1174 deletions

View File

@@ -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;

View File

@@ -41,7 +41,7 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View File

@@ -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");
}

View File

@@ -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 && (

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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

View File

@@ -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";

View File

@@ -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 () => {

View File

@@ -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);
}
};

View File

@@ -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) => {

View File

@@ -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) => {

View File

@@ -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)}

View File

@@ -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);
}
};

View File

@@ -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}`);
}

View File

@@ -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);
}
},