Merge pull request #2925 from spacedriveapp/space-item-cleanup

Space item cleanup
This commit is contained in:
Jamie Pine
2025-12-23 01:03:49 -08:00
committed by GitHub
65 changed files with 4027 additions and 2287 deletions

View File

@@ -119,6 +119,7 @@ impl From<RevokeArgs> for DeviceRevokeInput {
fn from(args: RevokeArgs) -> Self {
Self {
device_id: args.device_id,
remove_from_library: false,
}
}
}

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

@@ -7,6 +7,7 @@ import {
LocationCacheDemo,
PopoutInspector,
QuickPreview,
JobsScreen,
Settings,
PlatformProvider,
SpacedriveProvider,
@@ -81,6 +82,8 @@ function App() {
setRoute("/quick-preview");
} else if (label.startsWith("cache-demo")) {
setRoute("/cache-demo");
} else if (label.startsWith("job-manager")) {
setRoute("/job-manager");
}
// Tell Tauri window is ready to be shown
@@ -264,6 +267,20 @@ function App() {
);
}
if (route === "/job-manager") {
return (
<PlatformProvider platform={platform}>
<SpacedriveProvider client={client}>
<ServerProvider>
<div className="h-screen bg-app overflow-hidden rounded-[10px] border border-transparent frame">
<JobsScreen />
</div>
</ServerProvider>
</SpacedriveProvider>
</PlatformProvider>
);
}
return (
<PlatformProvider platform={platform}>
<Explorer client={client} />

View File

@@ -295,6 +295,21 @@ impl DeviceManager {
Ok(())
}
/// Remove a specific paired device from the cache by device ID
/// Used when a device is unpaired/revoked
pub fn remove_paired_device_from_cache(&self, device_id: Uuid) -> Result<(), DeviceError> {
let mut cache = self
.paired_device_cache
.write()
.map_err(|_| DeviceError::LockPoisoned)?;
// Find and remove the device by its ID (search by value)
cache.retain(|_slug, &mut cached_id| cached_id != device_id);
tracing::debug!("Removed device {} from DeviceManager cache", device_id);
Ok(())
}
/// Get the current device as a domain Device object
pub async fn current_device(&self) -> Device {
let config = self.config.read().unwrap();

View File

@@ -905,7 +905,8 @@ impl JobManager {
device_id,
status,
progress: progress_percentage,
started_at: chrono::Utc::now(), // TODO: Get actual start time
created_at: chrono::Utc::now(), // Running jobs use current time as fallback
started_at: Some(chrono::Utc::now()), // Running jobs have started
completed_at: None,
error_message: None,
parent_job_id: None,
@@ -993,7 +994,8 @@ impl JobManager {
device_id,
status: current_status,
progress: progress_percentage,
started_at: chrono::Utc::now(), // TODO: Get from DB
created_at: chrono::Utc::now(), // Running jobs use current time as fallback
started_at: Some(chrono::Utc::now()), // Running jobs have started
completed_at: None,
error_message: None,
parent_job_id: None,
@@ -1069,7 +1071,8 @@ impl JobManager {
device_id,
status,
progress,
started_at: j.started_at.unwrap_or(j.created_at),
created_at: j.created_at,
started_at: j.started_at,
completed_at: j.completed_at,
error_message: j.error_message,
parent_job_id: j.parent_job_id.and_then(|s| s.parse::<Uuid>().ok()),
@@ -1117,7 +1120,8 @@ impl JobManager {
device_id,
status,
progress,
started_at: chrono::Utc::now(), // TODO: Get actual start time from DB
created_at: chrono::Utc::now(), // Running jobs use current time as fallback
started_at: Some(chrono::Utc::now()), // Running jobs have started
completed_at: None, // Running jobs aren't completed yet
error_message: None, // TODO: Get from handle if failed
parent_job_id: None, // TODO: Get from DB if needed
@@ -1157,7 +1161,8 @@ impl JobManager {
device_id,
status,
progress,
started_at: j.started_at.unwrap_or(j.created_at),
created_at: j.created_at,
started_at: j.started_at,
completed_at: j.completed_at,
error_message: j.error_message,
parent_job_id: j.parent_job_id.and_then(|s| s.parse::<Uuid>().ok()),

View File

@@ -165,7 +165,8 @@ pub struct JobInfo {
pub device_id: Uuid, // Device running this job
pub status: JobStatus,
pub progress: f32,
pub started_at: chrono::DateTime<chrono::Utc>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub started_at: Option<chrono::DateTime<chrono::Utc>>,
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
pub error_message: Option<String>,
pub parent_job_id: Option<Uuid>,

View File

@@ -1447,8 +1447,8 @@ impl Library {
UPDATE content_kinds
SET file_count = (
SELECT COUNT(*)
FROM content_identity
WHERE content_identity.kind_id = content_kinds.id
FROM content_identities
WHERE content_identities.kind_id = content_kinds.id
)
"#
.to_owned(),

View File

@@ -359,8 +359,21 @@ impl IndexPersistence for MemoryAdapter {
};
if let Some(content_kind) = content_kind {
self.emit_resource_changed(entry_uuid, &entry.path, &metadata, content_kind)
.await;
// Skip event emission for hidden files (dotfiles) to match query filtering behavior.
// Hidden files are still indexed but won't trigger UI updates since they're
// filtered out by default in directory_listing queries.
// TODO: make this configurable
let is_hidden = entry
.path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false);
if !is_hidden {
self.emit_resource_changed(entry_uuid, &entry.path, &metadata, content_kind)
.await;
}
}
Ok(entry_id)

View File

@@ -136,10 +136,6 @@ impl IndexerJobConfig {
}
}
/// Creates config for ephemeral browsing (external drives, network shares).
///
/// Hidden files (dotfiles) are filtered by default to match typical file browser
/// behavior. Use `rule_toggles.no_hidden = false` if hidden files are needed.
pub fn ephemeral_browse(path: SdPath, scope: IndexScope) -> Self {
Self {
location_id: None,
@@ -152,13 +148,7 @@ impl IndexerJobConfig {
} else {
None
},
// Filter hidden files for ephemeral browsing to match file browser expectations
// and prevent event/query mismatch where hidden files emit events but are
// filtered from query results.
rule_toggles: super::rules::RuleToggles {
no_hidden: true,
..Default::default()
},
rule_toggles: Default::default(),
}
}

View File

@@ -10,6 +10,7 @@ use crate::{
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
use tracing::warn;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
@@ -65,8 +66,7 @@ impl LibraryAction for JobCancelAction {
success: true,
}),
Err(e) => {
// Return success=false instead of error for better UX
eprintln!("Failed to cancel job: {}", e);
warn!("Failed to cancel job {}: {}", self.input.job_id, e);
Ok(JobCancelOutput {
job_id: self.input.job_id,
success: false,

View File

@@ -10,6 +10,7 @@ use crate::{
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
use tracing::warn;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
@@ -65,8 +66,7 @@ impl LibraryAction for JobPauseAction {
success: true,
}),
Err(e) => {
// Return success=false instead of error for better UX
eprintln!("Failed to pause job: {}", e);
warn!("Failed to pause job {}: {}", self.input.job_id, e);
Ok(JobPauseOutput {
job_id: self.input.job_id,
success: false,

View File

@@ -10,6 +10,7 @@ use crate::{
use serde::{Deserialize, Serialize};
use specta::Type;
use std::sync::Arc;
use tracing::warn;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
@@ -65,8 +66,7 @@ impl LibraryAction for JobResumeAction {
success: true,
}),
Err(e) => {
// Return success=false instead of error for better UX
eprintln!("Failed to resume job: {}", e);
warn!("Failed to resume job {}: {}", self.input.job_id, e);
Ok(JobResumeOutput {
job_id: self.input.job_id,
success: false,

View File

@@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
@@ -8,7 +9,8 @@ pub struct JobInfoOutput {
pub name: String,
pub status: crate::infra::job::types::JobStatus,
pub progress: f32,
pub started_at: chrono::DateTime<chrono::Utc>,
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
pub error_message: Option<String>,
}

View File

@@ -49,6 +49,7 @@ impl LibraryQuery for JobInfoQuery {
name: j.name,
status: j.status,
progress: j.progress,
created_at: j.created_at,
started_at: j.started_at,
completed_at: j.completed_at,
error_message: j.error_message,

View File

@@ -1,4 +1,5 @@
use crate::infra::job::types::ActionContextInfo;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use specta::Type;
use uuid::Uuid;
@@ -12,6 +13,9 @@ pub struct JobListItem {
pub progress: f32,
pub action_type: Option<String>,
pub action_context: Option<ActionContextInfo>,
pub created_at: DateTime<Utc>,
pub started_at: Option<DateTime<Utc>>,
pub completed_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Type)]

View File

@@ -56,6 +56,9 @@ impl LibraryQuery for JobListQuery {
progress: j.progress,
action_type: j.action_type,
action_context: j.action_context,
created_at: j.created_at,
started_at: j.started_at,
completed_at: j.completed_at,
})
.collect();
Ok(JobListOutput { jobs: items })

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
pub struct DeviceRevokeAction {
pub device_id: uuid::Uuid,
pub remove_from_library: bool,
}
impl CoreAction for DeviceRevokeAction {
@@ -13,6 +14,7 @@ impl CoreAction for DeviceRevokeAction {
fn from_input(input: Self::Input) -> std::result::Result<Self, String> {
Ok(Self {
device_id: input.device_id,
remove_from_library: input.remove_from_library,
})
}
@@ -20,22 +22,135 @@ impl CoreAction for DeviceRevokeAction {
self,
context: Arc<crate::context::CoreContext>,
) -> std::result::Result<Self::Output, ActionError> {
tracing::info!("Revoking device: {}", self.device_id);
let net = context
.get_networking()
.await
.ok_or_else(|| ActionError::Internal("Networking not initialized".to_string()))?;
// Remove from registry state and persistence
// Remove from network registry state and persistence
{
let reg = net.device_registry();
let mut guard = reg.write().await;
let _ = guard.remove_device(self.device_id);
let _ = guard.remove_paired_device(self.device_id).await;
tracing::info!(
"Removing device {} from network registry in-memory state",
self.device_id
);
if let Err(e) = guard.remove_device(self.device_id) {
tracing::warn!("Failed to remove device from network registry: {}", e);
}
tracing::info!(
"Removing device {} from network encrypted persistence",
self.device_id
);
match guard.remove_paired_device(self.device_id).await {
Ok(removed) => {
if removed {
tracing::info!(
"Device {} removed from network persistent storage",
self.device_id
);
} else {
tracing::warn!(
"Device {} not found in network persistent storage (already removed?)",
self.device_id
);
}
}
Err(e) => {
tracing::error!("Failed to remove device from network persistence: {}", e);
return Err(ActionError::Internal(format!(
"Failed to remove device from network persistence: {}",
e
)));
}
}
}
// Remove from all library databases (if requested)
if self.remove_from_library {
tracing::info!(
"Removing device {} from library databases (remove_from_library=true)",
self.device_id
);
let libraries = context.libraries().await;
let mut removed_from_libraries = 0;
for library in libraries.get_open_libraries().await {
let db = library.db().conn();
// Delete device from library database
use crate::infra::db::entities::device;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
match device::Entity::delete_many()
.filter(device::Column::Uuid.eq(self.device_id))
.exec(db)
.await
{
Ok(result) => {
if result.rows_affected > 0 {
tracing::info!(
"Device {} removed from library {} database",
self.device_id,
library.id()
);
removed_from_libraries += 1;
}
}
Err(e) => {
tracing::warn!(
"Failed to remove device from library {} database: {}",
library.id(),
e
);
}
}
}
if removed_from_libraries > 0 {
tracing::info!(
"Device {} removed from {} library database(s)",
self.device_id,
removed_from_libraries
);
} else {
tracing::warn!(
"Device {} not found in any library databases (may have been removed already)",
self.device_id
);
}
} else {
tracing::info!(
"Skipping library database removal for device {} (remove_from_library=false)",
self.device_id
);
}
// Remove from DeviceManager cache
tracing::info!(
"Removing device {} from DeviceManager cache",
self.device_id
);
if let Err(e) = context
.device_manager
.remove_paired_device_from_cache(self.device_id)
{
tracing::warn!("Failed to remove device from cache: {}", e);
}
// Emit ResourceDeleted event
tracing::info!(
"Emitting ResourceDeleted event for device {}",
self.device_id
);
use crate::domain::resource::EventEmitter;
crate::domain::device::Device::emit_deleted(self.device_id, &context.events);
tracing::info!("Device {} successfully revoked", self.device_id);
Ok(DeviceRevokeOutput { revoked: true })
}

View File

@@ -5,4 +5,11 @@ use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct DeviceRevokeInput {
pub device_id: Uuid,
/// Whether to also remove the device from all library databases
///
/// If false (default), only unpairs from network but keeps device history in libraries.
/// If true, completely removes device from libraries (deletes all records).
#[serde(default)]
pub remove_from_library: bool,
}

View File

@@ -192,11 +192,41 @@ impl DevicePersistence {
/// Remove a paired device
pub async fn remove_paired_device(&self, device_id: Uuid) -> Result<bool> {
tracing::debug!(
"Attempting to remove paired device {} from persistence",
device_id
);
let mut devices = self.load_paired_devices().await?;
let removed = devices.remove(&device_id).is_some();
if removed {
tracing::info!("Device {} found in paired devices, removing...", device_id);
// Delete the individual device key from KeyManager
let key = Self::device_key(device_id);
tracing::debug!("Deleting device key '{}' from KeyManager", key);
if let Err(e) = self.key_manager.delete_secret(&key).await {
tracing::warn!("Failed to delete device key {}: {}", key, e);
} else {
tracing::info!("Device key '{}' deleted from KeyManager", key);
}
// Update the device list (removes from paired_devices_list)
tracing::debug!(
"Updating paired devices list (now {} devices)",
devices.len()
);
self.save_paired_devices(&devices).await?;
tracing::info!(
"Device {} successfully removed from persistence ({} devices remaining)",
device_id,
devices.len()
);
} else {
tracing::warn!("Device {} not found in paired devices list", device_id);
}
Ok(removed)

View File

@@ -439,15 +439,24 @@ impl DeviceRegistry {
/// Remove a device from the registry
pub fn remove_device(&mut self, device_id: Uuid) -> Result<()> {
if let Some(state) = self.devices.remove(&device_id) {
// Clean up mappings
// Clean up node-to-device mappings for all states
match &state {
DeviceState::Discovered { node_id, .. } | DeviceState::Pairing { node_id, .. } => {
self.node_to_device.remove(node_id);
}
DeviceState::Pairing { session_id, .. } => {
self.session_to_device.remove(session_id);
DeviceState::Paired { info, .. }
| DeviceState::Connected { info, .. }
| DeviceState::Disconnected { info, .. } => {
// Extract node ID from network fingerprint and clean up mapping
if let Ok(node_id) = info.network_fingerprint.node_id.parse::<iroh::NodeId>() {
self.node_to_device.remove(&node_id);
}
}
_ => {}
}
// Clean up session-to-device mapping for pairing state
if let DeviceState::Pairing { session_id, .. } = &state {
self.session_to_device.remove(session_id);
}
}

View File

@@ -8,6 +8,8 @@ import splatOgg from "./splat.ogg";
import splatMp3 from "./splat.mp3";
import splatTriggerOgg from "./splat-trigger.ogg";
import splatTriggerMp3 from "./splat-trigger.mp3";
import jobDoneOgg from "./job-done.ogg";
import jobDoneMp3 from "./job-done.mp3";
/**
* Play a sound effect
@@ -35,4 +37,5 @@ export const sounds = {
pairing: () => playSound(pairingOgg, pairingMp3, 0.5),
splat: () => playSound(splatOgg, splatMp3, 0.05),
splatTrigger: () => playSound(splatTriggerOgg, splatTriggerMp3, 0.3),
jobDone: () => playSound(jobDoneOgg, jobDoneMp3, 0.4),
};

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -12,12 +12,7 @@ import { Dialogs } from "@sd/ui";
import { Inspector, type InspectorVariant } from "./Inspector";
import { TopBarProvider, TopBar } from "./TopBar";
import { motion, AnimatePresence } from "framer-motion";
import {
ExplorerProvider,
useExplorer,
Sidebar,
getSpaceItemKeyFromRoute,
} from "./components/Explorer";
import { ExplorerProvider, useExplorer, Sidebar } from "./components/Explorer";
import {
SelectionProvider,
useSelection,
@@ -65,7 +60,7 @@ import { House, Clock, Heart, Folders } from "@phosphor-icons/react";
* we update the preview to show the newly selected file.
*/
function QuickPreviewSyncer() {
const { quickPreviewFileId, setQuickPreviewFileId } = useExplorer();
const { quickPreviewFileId, openQuickPreview } = useExplorer();
const { selectedFiles } = useSelection();
useEffect(() => {
@@ -76,9 +71,9 @@ function QuickPreviewSyncer() {
selectedFiles.length === 1 &&
selectedFiles[0].id !== quickPreviewFileId
) {
setQuickPreviewFileId(selectedFiles[0].id);
openQuickPreview(selectedFiles[0].id);
}
}, [selectedFiles, quickPreviewFileId, setQuickPreviewFileId]);
}, [selectedFiles, quickPreviewFileId, openQuickPreview]);
return null;
}
@@ -163,19 +158,9 @@ function ExplorerLayoutContent() {
tagModeActive,
setTagModeActive,
viewMode,
setSpaceItemId,
currentPath,
} = useExplorer();
// Sync route with explorer context for view preferences
useEffect(() => {
const spaceItemKey = getSpaceItemKeyFromRoute(
location.pathname,
location.search,
);
setSpaceItemId(spaceItemKey);
}, [location.pathname, location.search, setSpaceItemId]);
// Check if we're on Overview (hide inspector) or in Knowledge view (has its own inspector)
const isOverview = location.pathname === "/";
const isKnowledgeView = viewMode === "knowledge";
@@ -769,7 +754,8 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
{activeItem.itemType === "Overview" && "Overview"}
{activeItem.itemType === "Recents" && "Recents"}
{activeItem.itemType === "Favorites" && "Favorites"}
{activeItem.itemType === "FileKinds" && "File Kinds"}
{activeItem.itemType === "FileKinds" &&
"File Kinds"}
</span>
</div>
) : activeItem?.label ? (

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

@@ -5,59 +5,62 @@ import { Title } from "./Title";
import { Metadata } from "./Metadata";
interface FileProps {
file: FileType;
selected?: boolean;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
onMouseDown?: (e: React.MouseEvent) => void;
onMouseMove?: (e: React.MouseEvent) => void;
onMouseUp?: (e: React.MouseEvent) => void;
onMouseLeave?: (e: React.MouseEvent) => void;
layout?: "column" | "row";
children?: React.ReactNode;
className?: string;
"data-file-id"?: string;
file: FileType;
selected?: boolean;
onClick?: (e: React.MouseEvent) => void;
onDoubleClick?: (e: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
onMouseDown?: (e: React.MouseEvent) => void;
onMouseMove?: (e: React.MouseEvent) => void;
onMouseUp?: (e: React.MouseEvent) => void;
onMouseLeave?: (e: React.MouseEvent) => void;
layout?: "column" | "row";
children?: React.ReactNode;
className?: string;
"data-file-id"?: string;
}
function FileComponent({
file,
selected,
onClick,
onDoubleClick,
onContextMenu,
onMouseDown,
onMouseMove,
onMouseUp,
onMouseLeave,
layout = "column",
children,
className,
"data-file-id": dataFileId,
file,
selected,
onClick,
onDoubleClick,
onContextMenu,
onMouseDown,
onMouseMove,
onMouseUp,
onMouseLeave,
layout = "column",
children,
className,
"data-file-id": dataFileId,
}: FileProps) {
return (
<div
data-file-id={dataFileId}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
className={clsx(
"cursor-default transition-colors",
layout === "column" ? "flex flex-col" : "flex flex-row items-center",
className
)}
>
{children}
</div>
);
return (
<div
data-file-id={dataFileId}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onMouseLeave={onMouseLeave}
tabIndex={-1}
className={clsx(
"cursor-default transition-colors outline-none focus:outline-none",
layout === "column"
? "flex flex-col"
: "flex flex-row items-center",
className,
)}
>
{children}
</div>
);
}
export const File = Object.assign(FileComponent, {
Thumb,
Title,
Metadata,
Thumb,
Title,
Metadata,
});

View File

@@ -10,7 +10,7 @@ import {
RadioButtonIcon,
} from "@phosphor-icons/react";
import type { SdPath, LibraryDeviceInfo } from "@sd/ts-client";
import { getDeviceIconBySlug, useLibraryMutation } from "@sd/ts-client";
import { getDeviceIcon, useLibraryMutation } from "@sd/ts-client";
import { sdPathToUri } from "../utils";
import LaptopIcon from "@sd/assets/icons/Laptop.png";
import { useNormalizedQuery } from "@sd/ts-client";
@@ -250,6 +250,9 @@ function IndexIndicator({ path }: { path: SdPath }) {
export function PathBar({ path, devices, onNavigate }: PathBarProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [isShiftHeld, setIsShiftHeld] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState("");
const [editingAsUri, setEditingAsUri] = useState(false);
const { navigateToView } = useExplorer();
const uri = sdPathToUri(path);
const currentDir = getCurrentDirectoryName(path);
@@ -264,7 +267,7 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
(d) => d.slug === deviceSlug,
);
return {
icon: getDeviceIconBySlug(deviceSlug, devices),
icon: device ? getDeviceIcon(device) : LaptopIcon,
device,
};
}
@@ -278,6 +281,68 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
}
};
const enterEditMode = (initialValue: string, asUri: boolean) => {
setIsEditing(true);
setEditValue(initialValue);
setEditingAsUri(asUri);
};
const exitEditMode = () => {
setIsEditing(false);
setEditValue("");
setEditingAsUri(false);
};
const handleContainerClick = (e: React.MouseEvent) => {
// Only enter edit mode if clicking the container itself, not buttons/segments
if (e.target === e.currentTarget || (e.target as HTMLElement).tagName === "INPUT") {
const isUriMode = showUri;
const valueToEdit = isUriMode ? uri : ("Physical" in path ? path.Physical.path : uri);
enterEditMode(valueToEdit, isUriMode);
}
};
const handleEditKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
submitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
exitEditMode();
}
};
const submitEdit = () => {
const trimmed = editValue.trim();
if (!trimmed) {
exitEditMode();
return;
}
try {
if (editingAsUri) {
// Try to parse as SdPath JSON
const parsed = JSON.parse(trimmed) as SdPath;
onNavigate(parsed);
} else {
// Parse as file path string
if ("Physical" in path) {
const newPath: SdPath = {
Physical: {
device_slug: path.Physical.device_slug,
path: trimmed.startsWith("/") ? trimmed : `/${trimmed}`,
},
};
onNavigate(newPath);
}
}
} catch (error) {
console.error("Failed to parse path:", error);
}
exitEditMode();
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Shift") setIsShiftHeld(true);
@@ -297,7 +362,7 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
const showUri = isExpanded && isShiftHeld;
// Calculate widths for three states
// Calculate widths for different states
const collapsedWidth = currentDir.length * 8.5 + 70;
const breadcrumbsWidth = Math.min(
segments.reduce((sum, seg) => sum + seg.name.length * 6.5, 0) +
@@ -306,29 +371,37 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
600,
);
const uriWidth = Math.min(uri.length * 7 + 70, 600);
const editWidth = Math.max(200, Math.min(editValue.length * 7 + 70, 600));
const currentWidth = !isExpanded
? collapsedWidth
: showUri
? uriWidth
: breadcrumbsWidth;
const currentWidth = isEditing
? editWidth
: !isExpanded
? collapsedWidth
: showUri
? uriWidth
: breadcrumbsWidth;
return (
<div className="flex items-center gap-2">
<motion.div
animate={{ width: currentWidth }}
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
onMouseEnter={() => setIsExpanded(true)}
onMouseLeave={() => setIsExpanded(false)}
onMouseEnter={() => !isEditing && setIsExpanded(true)}
onMouseLeave={() => !isEditing && setIsExpanded(false)}
onClick={handleContainerClick}
className={clsx(
"flex items-center gap-1.5 h-8 px-3 rounded-full",
"backdrop-blur-xl border border-sidebar-line/30",
"bg-sidebar-box/20 transition-colors",
"focus-within:bg-sidebar-box/30 focus-within:border-sidebar-line/40",
!isEditing && "cursor-text",
)}
>
<button
onClick={handleDeviceClick}
onClick={(e) => {
e.stopPropagation();
handleDeviceClick();
}}
disabled={!deviceInfo.device}
title={
deviceInfo.device
@@ -349,7 +422,24 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
/>
</button>
{showUri ? (
{isEditing ? (
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={exitEditMode}
autoFocus
className={clsx(
"bg-transparent border-0 outline-none ring-0 flex-1 min-w-0",
"text-xs font-medium text-sidebar-ink",
"placeholder:text-sidebar-inkFaint",
"focus:ring-0 focus:outline-none",
editingAsUri && "font-mono",
)}
placeholder={editingAsUri ? "Enter SdPath JSON..." : "Enter path..."}
/>
) : showUri ? (
<input
type="text"
value={uri}
@@ -373,9 +463,10 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
className="flex items-center gap-1 flex-shrink-0"
>
<button
onClick={() =>
!isLast && onNavigate(segment.path)
}
onClick={(e) => {
e.stopPropagation();
!isLast && onNavigate(segment.path);
}}
disabled={isLast}
className={clsx(
"text-xs font-medium transition-colors whitespace-nowrap",
@@ -386,7 +477,18 @@ export function PathBar({ path, devices, onNavigate }: PathBarProps) {
>
{segment.name}
</button>
{!isLast && <CaretRight size={12} />}
{!isLast && (
<button
onClick={(e) => {
e.stopPropagation();
const valueToEdit = "Physical" in path ? path.Physical.path : uri;
enterEditMode(valueToEdit, false);
}}
className="opacity-50 hover:opacity-100 transition-opacity cursor-text"
>
<CaretRight size={12} />
</button>
)}
</div>
);
})}

View File

@@ -1,19 +1,19 @@
import {
createContext,
useContext,
useState,
useReducer,
useMemo,
useEffect,
useCallback,
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,
@@ -23,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";
@@ -40,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;
@@ -102,7 +322,6 @@ interface ExplorerState {
setInspectorVisible: (visible: boolean) => void;
quickPreviewFileId: string | null;
setQuickPreviewFileId: (fileId: string | null) => void;
openQuickPreview: (fileId: string) => void;
closeQuickPreview: () => void;
@@ -112,338 +331,253 @@ interface ExplorerState {
tagModeActive: boolean;
setTagModeActive: (active: boolean) => void;
devices: Map<string, any>;
devices: Map<string, Device>;
setSpaceItemId: (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,
);
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 when space item changes
useEffect(() => {
const prefs = viewPrefs.getPreferences(spaceItemKey);
if (prefs) {
setViewModeInternal(prefs.viewMode);
if (prefs.viewSettings) {
setViewSettingsInternal((prev) => ({
...prev,
...prefs.viewSettings,
}));
}
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,
};
}
// 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],
);
// 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(() => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
const entry = history[newIndex];
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(() => {
if (historyIndex < history.length - 1) {
const newIndex = historyIndex + 1;
const entry = history[newIndex];
setHistoryIndex(newIndex);
const pathKey = getPathKey(currentTarget);
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,
}),
[
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,
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,
],
);
@@ -454,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
@@ -41,8 +41,8 @@ export function useExplorerKeyboard() {
const handleKeyDown = async (e: KeyboardEvent) => {
// Arrow keys: Navigation
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
// Skip column view - each column handles its own keyboard navigation
if (viewMode === "column") {
// Skip views that handle their own keyboard navigation
if (viewMode === "column" || viewMode === "media" || viewMode === "grid") {
return;
}
@@ -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

@@ -42,7 +42,7 @@ export const ColumnItem = memo(
});
return (
<div ref={setNodeRef} {...listeners} {...attributes}>
<div ref={setNodeRef} {...listeners} {...attributes} tabIndex={-1} className="outline-none focus:outline-none">
<FileComponent
file={file}
selected={selected && !isDragging}

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);
}
};
@@ -127,7 +127,8 @@ export const FileCard = memo(
{...listeners}
{...attributes}
data-file-id={file.id}
className="relative"
tabIndex={-1}
className="relative outline-none focus:outline-none"
>
{/* Drop indicator for folders */}
{isFolder && isDropOver && (

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useMemo } from "react";
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useExplorer } from "../../context";
import { useSelection } from "../../SelectionContext";
@@ -10,240 +10,305 @@ import { useVirtualListing } from "../../hooks/useVirtualListing";
const VIRTUALIZATION_THRESHOLD = 0; // Disabled - always virtualize
export function GridView() {
const { currentPath, sortBy, viewSettings, setCurrentFiles } = useExplorer();
const { isSelected, focusedIndex, selectedFiles, selectFile, clearSelection } = useSelection();
const { gridSize, gapSize } = viewSettings;
const { currentPath, sortBy, viewSettings, setCurrentFiles } =
useExplorer();
const {
isSelected,
focusedIndex,
setFocusedIndex,
selectedFiles,
selectFile,
clearSelection,
setSelectedFiles,
} = useSelection();
const { gridSize, gapSize } = viewSettings;
// Check for virtual listing first
const { files: virtualFiles, isVirtualView } = useVirtualListing();
// Check for virtual listing first
const { files: virtualFiles, isVirtualView } = useVirtualListing();
const directoryQuery = useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: currentPath
? {
path: currentPath,
limit: null,
include_hidden: false,
sort_by: sortBy as DirectorySortBy,
folders_first: viewSettings.foldersFirst,
}
: null!,
resourceType: "file",
enabled: !!currentPath && !isVirtualView,
pathScope: currentPath ?? undefined,
});
const directoryQuery = useNormalizedQuery({
wireMethod: "query:files.directory_listing",
input: currentPath
? {
path: currentPath,
limit: null,
include_hidden: false,
sort_by: sortBy as DirectorySortBy,
folders_first: viewSettings.foldersFirst,
}
: null!,
resourceType: "file",
enabled: !!currentPath && !isVirtualView,
pathScope: currentPath ?? undefined,
});
const files = isVirtualView ? (virtualFiles || []) : (directoryQuery.data?.files || []);
const files = isVirtualView
? virtualFiles || []
: (directoryQuery.data as any)?.files || [];
// Update current files in explorer context for quick preview navigation
useEffect(() => {
setCurrentFiles(files);
}, [files, setCurrentFiles]);
// Update current files in explorer context for quick preview navigation
useEffect(() => {
setCurrentFiles(files);
}, [files, setCurrentFiles]);
const handleContainerClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
clearSelection();
}
};
const handleContainerClick = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
clearSelection();
}
};
// Conditional virtualization - use simple grid for small directories
const shouldVirtualize = files.length > VIRTUALIZATION_THRESHOLD;
// Conditional virtualization - use simple grid for small directories
const shouldVirtualize = files.length > VIRTUALIZATION_THRESHOLD;
if (!shouldVirtualize) {
return (
<div
className="grid p-3 min-h-full"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))`,
gridAutoRows: 'max-content',
gap: `${gapSize}px`,
}}
onClick={handleContainerClick}
>
{files.map((file, index) => (
<FileCard
key={file.id}
file={file}
fileIndex={index}
allFiles={files}
selected={isSelected(file.id)}
focused={index === focusedIndex}
selectedFiles={selectedFiles}
selectFile={selectFile}
/>
))}
</div>
);
}
if (!shouldVirtualize) {
return (
<div
className="grid p-3 min-h-full"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${gridSize}px, 1fr))`,
gridAutoRows: "max-content",
gap: `${gapSize}px`,
}}
onClick={handleContainerClick}
>
{files.map((file, index) => (
<FileCard
key={file.id}
file={file}
fileIndex={index}
allFiles={files}
selected={isSelected(file.id)}
focused={index === focusedIndex}
selectedFiles={selectedFiles}
selectFile={selectFile}
/>
))}
</div>
);
}
return (
<VirtualizedGrid
files={files}
gridSize={gridSize}
gapSize={gapSize}
isSelected={isSelected}
focusedIndex={focusedIndex}
selectedFiles={selectedFiles}
selectFile={selectFile}
onContainerClick={handleContainerClick}
/>
);
return (
<VirtualizedGrid
files={files}
gridSize={gridSize}
gapSize={gapSize}
isSelected={isSelected}
focusedIndex={focusedIndex}
setFocusedIndex={setFocusedIndex}
selectedFiles={selectedFiles}
selectFile={selectFile}
setSelectedFiles={setSelectedFiles}
onContainerClick={handleContainerClick}
/>
);
}
interface VirtualizedGridProps {
files: File[];
gridSize: number;
gapSize: number;
isSelected: (id: string) => boolean;
focusedIndex: number;
selectedFiles: File[];
selectFile: (file: File, files: File[], multi?: boolean, range?: boolean) => void;
onContainerClick: (e: React.MouseEvent) => void;
files: File[];
gridSize: number;
gapSize: number;
isSelected: (id: string) => boolean;
focusedIndex: number;
setFocusedIndex: (index: number) => void;
selectedFiles: File[];
selectFile: (
file: File,
files: File[],
multi?: boolean,
range?: boolean,
) => void;
setSelectedFiles: (files: File[]) => void;
onContainerClick: (e: React.MouseEvent) => void;
}
function VirtualizedGrid({
files,
gridSize,
gapSize,
isSelected,
focusedIndex,
selectedFiles,
selectFile,
onContainerClick,
files,
gridSize,
gapSize,
isSelected,
focusedIndex,
setFocusedIndex,
selectedFiles,
selectFile,
setSelectedFiles,
onContainerClick,
}: VirtualizedGridProps) {
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(0);
const parentRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number | null>(null);
const [isInitialized, setIsInitialized] = useState(false);
// Track container width with ResizeObserver
useEffect(() => {
const element = parentRef.current;
if (!element) return;
// Synchronous measurement before paint to prevent layout shift
useLayoutEffect(() => {
const element = parentRef.current;
if (!element) return;
let rafId: number | null = null;
const updateWidth = () => {
const newWidth = element.offsetWidth;
const updateWidth = () => {
if (rafId) return;
if (newWidth > 0) {
setContainerWidth(newWidth - 24);
setIsInitialized(true);
}
};
rafId = requestAnimationFrame(() => {
rafId = null;
const newWidth = element.offsetWidth;
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(element);
if (newWidth > 0) {
// Subtract padding (p-3 = 12px on each side)
setContainerWidth(newWidth - 24);
}
});
};
// Immediate measurement
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(element);
window.addEventListener("resize", updateWidth);
return () => {
resizeObserver.disconnect();
};
}, []);
// Set initial width
updateWidth();
// Calculate columns (mimic auto-fill behavior)
const columns = useMemo(() => {
if (!containerWidth) return 1;
return () => {
if (rafId) cancelAnimationFrame(rafId);
resizeObserver.disconnect();
window.removeEventListener("resize", updateWidth);
};
}, []);
// Mimic repeat(auto-fill, minmax(gridSize, 1fr))
const minItemWidth = gridSize;
const totalGapWidth = gapSize;
// Calculate columns (mimic auto-fill behavior)
const columns = useMemo(() => {
if (!containerWidth) return 1;
// Calculate how many items fit
let cols = 1;
while (true) {
const totalGaps = (cols - 1) * gapSize;
const requiredWidth = cols * minItemWidth + totalGaps;
// Mimic repeat(auto-fill, minmax(gridSize, 1fr))
const minItemWidth = gridSize;
const totalGapWidth = gapSize;
if (requiredWidth <= containerWidth) {
cols++;
} else {
cols--;
break;
}
}
// Calculate how many items fit
let cols = 1;
while (true) {
const totalGaps = (cols - 1) * gapSize;
const requiredWidth = cols * minItemWidth + totalGaps;
return Math.max(1, cols);
}, [containerWidth, gridSize, gapSize]);
if (requiredWidth <= containerWidth) {
cols++;
} else {
cols--;
break;
}
}
const rowCount = Math.ceil(files.length / columns);
const rowGap = 4; // Gap between rows
return Math.max(1, cols);
}, [containerWidth, gridSize, gapSize]);
// Row virtualizer
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => gridSize + gapSize + rowGap,
overscan: 5,
});
const rowCount = Math.ceil(files.length / columns);
const rowGap = 4; // Gap between rows
const virtualRows = rowVirtualizer.getVirtualItems();
// Row virtualizer
const rowVirtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => gridSize + gapSize + rowGap,
overscan: 5,
});
// Keyboard navigation with correct column count
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(
e.key,
)
) {
return;
}
if (files.length === 0) return;
const virtualRows = rowVirtualizer.getVirtualItems();
e.preventDefault();
return (
<div
ref={parentRef}
className="h-full overflow-auto"
onClick={onContainerClick}
>
<div
className="relative"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
paddingTop: '12px',
paddingBottom: '12px',
minHeight: '100%',
}}
>
{virtualRows.map((virtualRow) => {
const startIndex = virtualRow.index * columns;
const endIndex = Math.min(startIndex + columns, files.length);
const rowFiles = files.slice(startIndex, endIndex);
let newIndex = focusedIndex < 0 ? 0 : focusedIndex;
return (
<div
key={virtualRow.key}
className="absolute left-0 w-full px-3"
style={{
top: `${virtualRow.start}px`,
height: `${gridSize + gapSize}px`,
}}
>
{/* CSS Grid within row - preserves flex-to-fill */}
<div
className="grid h-full"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: `${gapSize}px`,
}}
>
{rowFiles.map((file, idx) => {
const fileIndex = startIndex + idx;
return (
<FileCard
key={file.id}
file={file}
fileIndex={fileIndex}
allFiles={files}
selected={isSelected(file.id)}
focused={fileIndex === focusedIndex}
selectedFiles={selectedFiles}
selectFile={selectFile}
/>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
if (e.key === "ArrowUp") {
newIndex = Math.max(0, newIndex - columns);
} else if (e.key === "ArrowDown") {
newIndex = Math.min(files.length - 1, newIndex + columns);
} else if (e.key === "ArrowLeft") {
newIndex = Math.max(0, newIndex - 1);
} else if (e.key === "ArrowRight") {
newIndex = Math.min(files.length - 1, newIndex + 1);
}
if (newIndex !== focusedIndex && files[newIndex]) {
setFocusedIndex(newIndex);
setSelectedFiles([files[newIndex]]);
// Scroll into view
const element = document.querySelector(
`[data-file-id="${files[newIndex].id}"]`,
);
if (element) {
element.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [files, focusedIndex, columns, setFocusedIndex, setSelectedFiles]);
return (
<div
ref={parentRef}
className="h-full overflow-auto"
onClick={onContainerClick}
>
<div
className="relative"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
paddingTop: "12px",
paddingBottom: "12px",
minHeight: "100%",
opacity: isInitialized ? 1 : 0,
transition: "opacity 0.1s",
}}
>
{virtualRows.map((virtualRow) => {
const startIndex = virtualRow.index * columns;
const endIndex = Math.min(
startIndex + columns,
files.length,
);
const rowFiles = files.slice(startIndex, endIndex);
return (
<div
key={virtualRow.key}
className="absolute left-0 w-full px-3"
style={{
top: `${virtualRow.start}px`,
height: `${gridSize + gapSize}px`,
}}
>
{/* CSS Grid within row - preserves flex-to-fill */}
<div
className="grid h-full"
style={{
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: `${gapSize}px`,
}}
>
{rowFiles.map((file, idx) => {
const fileIndex = startIndex + idx;
return (
<FileCard
key={file.id}
file={file}
fileIndex={fileIndex}
allFiles={files}
selected={isSelected(file.id)}
focused={fileIndex === focusedIndex}
selectedFiles={selectedFiles}
selectFile={selectFile}
/>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}

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) => {
@@ -95,7 +95,8 @@ export const TableRow = memo(
ref={measureRef}
data-index={index}
data-file-id={file.id}
className="relative"
tabIndex={-1}
className="relative outline-none focus:outline-none"
style={{ height: ROW_HEIGHT }}
onClick={handleClick}
onDoubleClick={handleDoubleClick}

View File

@@ -10,6 +10,7 @@ import {
import { useExplorer } from "../../context";
import { useSelection } from "../../SelectionContext";
import { useNormalizedQuery } from "../../../../context";
import type { File } from "@sd/ts-client";
import { MediaViewItem } from "./MediaViewItem";
import { DateHeader, DATE_HEADER_HEIGHT } from "./DateHeader";
import { formatDate, getItemDate, normalizeDateToMidnight } from "./utils";
@@ -22,7 +23,7 @@ export function MediaView() {
setSortBy,
setCurrentFiles,
} = useExplorer();
const { selectedFiles, selectFile, focusedIndex, isSelected, selectedFileIds } = useSelection();
const { selectedFiles, selectFile, focusedIndex, setFocusedIndex, setSelectedFiles, isSelected, selectedFileIds } = useSelection();
// Set default sort to "datetaken" when entering media view
useEffect(() => {
@@ -145,6 +146,49 @@ export function MediaView() {
}
}, [files, elementReady]);
// Keyboard navigation for media view
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
return;
}
if (files.length === 0) return;
e.preventDefault();
// Calculate columns based on container width
const itemWidth = gridSize + gapSize;
const cols = containerWidth > 0
? Math.max(4, Math.floor(containerWidth / itemWidth))
: 8;
let newIndex = focusedIndex;
if (e.key === "ArrowUp") {
newIndex = Math.max(0, focusedIndex - cols);
} else if (e.key === "ArrowDown") {
newIndex = Math.min(files.length - 1, focusedIndex + cols);
} else if (e.key === "ArrowLeft") {
newIndex = Math.max(0, focusedIndex - 1);
} else if (e.key === "ArrowRight") {
newIndex = Math.min(files.length - 1, focusedIndex + 1);
}
if (newIndex !== focusedIndex && files[newIndex]) {
setFocusedIndex(newIndex);
setSelectedFiles([files[newIndex]]);
// Scroll selected item into view
const element = document.querySelector(`[data-file-id="${files[newIndex].id}"]`);
if (element) {
element.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [files, focusedIndex, gridSize, gapSize, containerWidth, setFocusedIndex, setSelectedFiles]);
// Calculate columns and actual item size to fill available space
const { columns, actualItemSize } = useMemo(() => {
@@ -347,7 +391,8 @@ export function MediaView() {
return (
<div
key={file.id}
className="absolute"
tabIndex={-1}
className="absolute outline-none focus:outline-none"
style={{
top: `${rowTop}px`,
left: `${left}px`,

View File

@@ -61,8 +61,9 @@ export const MediaViewItem = memo(function MediaViewItem({
return (
<div
data-file-id={file.id}
tabIndex={-1}
className={clsx(
"relative overflow-hidden cursor-pointer transition-all w-full h-full group",
"relative overflow-hidden cursor-pointer transition-all w-full h-full group outline-none focus:outline-none",
selected && "ring-2 ring-accent ring-inset",
focused && !selected && "ring-2 ring-accent/50 ring-inset",
)}

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 JobManagerPopover({ className }: JobManagerPopoverProps) {
const [showOnlyRunning, setShowOnlyRunning] = useState(true);
// Unified hook for job data and badge/icon
const { activeJobCount, hasRunningJobs, jobs, pause, resume } = useJobs();
const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel } = useJobs();
// Reset filter to "active only" when popover opens
useEffect(() => {
@@ -97,6 +97,7 @@ export function JobManagerPopover({ className }: JobManagerPopoverProps) {
setShowOnlyRunning={setShowOnlyRunning}
pause={pause}
resume={resume}
cancel={cancel}
/>
)}
</Popover>
@@ -109,12 +110,14 @@ function JobManagerPopoverContent({
setShowOnlyRunning,
pause,
resume,
cancel,
}: {
jobs: any[];
showOnlyRunning: boolean;
setShowOnlyRunning: (value: boolean) => void;
pause: (jobId: string) => Promise<void>;
resume: (jobId: string) => Promise<void>;
cancel: (jobId: string) => Promise<void>;
}) {
const filteredJobs = showOnlyRunning
? jobs.filter((job) => job.status === "running" || job.status === "paused")
@@ -132,7 +135,7 @@ function JobManagerPopoverContent({
}}
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
>
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} />
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} onCancel={cancel} />
</motion.div>
);
}

View File

@@ -1,4 +1,4 @@
import { Pause, Play } from "@phosphor-icons/react";
import { Pause, Play, X } from "@phosphor-icons/react";
import { useState } from "react";
import clsx from "clsx";
import type { JobListItem } from "../types";
@@ -6,137 +6,182 @@ import { getJobDisplayName, formatDuration, timeAgo } from "../types";
import { JobStatusIndicator } from "../components/JobStatusIndicator";
interface JobRowProps {
job: JobListItem;
onPause?: (jobId: string) => void;
onResume?: (jobId: string) => void;
job: JobListItem;
onPause?: (jobId: string) => void;
onResume?: (jobId: string) => void;
onCancel?: (jobId: string) => void;
}
export function JobRow({ job, onPause, onResume }: JobRowProps) {
const [isHovered, setIsHovered] = useState(false);
export function JobRow({ job, onPause, onResume, onCancel }: JobRowProps) {
const [isHovered, setIsHovered] = useState(false);
const displayName = getJobDisplayName(job);
const showActionButton = job.status === "running" || job.status === "paused";
const canPause = job.status === "running" && onPause;
const canResume = job.status === "paused" && onResume;
const displayName = getJobDisplayName(job);
const showActionButton =
job.status === "running" || job.status === "paused";
const canPause = job.status === "running" && onPause;
const canResume = job.status === "paused" && onResume;
const canCancel = (job.status === "running" || job.status === "paused") && onCancel;
const handleAction = (e: React.MouseEvent) => {
e.stopPropagation();
if (canPause) {
onPause(job.id);
} else if (canResume) {
onResume(job.id);
}
};
const handleAction = (e: React.MouseEvent) => {
e.stopPropagation();
if (canPause) {
onPause(job.id);
} else if (canResume) {
onResume(job.id);
}
};
// Format progress percentage
const progressPercent = Math.round(job.progress * 100);
const handleCancel = (e: React.MouseEvent) => {
e.stopPropagation();
if (canCancel) {
onCancel(job.id);
}
};
// Get phase and message
const phase = job.current_phase;
const message = job.status_message;
// Format progress percentage
const progressPercent = Math.round(job.progress * 100);
// Calculate duration
const duration = job.completed_at
? new Date(job.completed_at).getTime() - new Date(job.created_at).getTime()
: Date.now() - new Date(job.created_at).getTime();
// Get phase and message
const phase = job.current_phase;
const message = job.status_message;
return (
<div
className={clsx(
"group relative flex items-center gap-4 px-4 py-3 border-b border-app-line/30",
"hover:bg-app-hover/50 transition-colors cursor-pointer"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Icon */}
<div className="flex-shrink-0">
<JobStatusIndicator job={job} />
</div>
// Calculate duration - prefer started_at for accuracy, fallback to created_at
const startTime = job.started_at || job.created_at;
const duration = startTime
? job.completed_at
? new Date(job.completed_at).getTime() -
new Date(startTime).getTime()
: Date.now() - new Date(startTime).getTime()
: 0;
{/* Main info */}
<div className="flex-1 min-w-0 flex items-center gap-6">
{/* Job name and details */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-medium text-ink truncate">
{displayName}
</h3>
{phase && (
<span className="text-xs text-ink-dull px-2 py-0.5 rounded-full bg-app-box">
{phase}
</span>
)}
</div>
{message && (
<p className="text-xs text-ink-dull truncate">
{message}
</p>
)}
</div>
return (
<div
className={clsx(
"group relative flex items-center gap-4 px-4 py-3 border-b border-app-line/30",
"hover:bg-app-hover/20",
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Icon */}
<div className="flex-shrink-0">
<JobStatusIndicator job={job} />
</div>
{/* Progress */}
{(job.status === "running" || job.status === "paused") && (
<div className="flex-shrink-0 w-32">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-app-line/30 rounded-full overflow-hidden">
<div
className="h-full bg-accent transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-xs font-medium text-ink-dull w-8 text-right">
{progressPercent}%
</span>
</div>
</div>
)}
{/* Main info */}
<div className="flex-1 min-w-0 flex items-center gap-6">
{/* Job name and details */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm mt-1 font-medium text-ink truncate">
{displayName}
</h3>
{phase && (
<span className="text-xs text-ink-dull px-2 py-0.5 rounded-full bg-app-box">
{phase}
</span>
)}
</div>
{message && (
<p className="text-xs text-ink-dull truncate">
{message}
</p>
)}
</div>
{/* Duration */}
<div className="flex-shrink-0 w-20 text-right">
<span className="text-xs text-ink-dull">
{formatDuration(duration)}
</span>
</div>
{/* Progress / Duration column */}
<div className="flex-shrink-0 w-32">
{job.status === "running" || job.status === "paused" ? (
// Show progress bar for active jobs
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-app-line/30 rounded-full overflow-hidden">
<div
className="h-full bg-accent transition-all duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="text-xs font-medium text-ink-dull w-8 text-right">
{progressPercent}%
</span>
</div>
) : job.status === "completed" ? (
// Show duration for completed jobs
<span className="text-xs text-ink-dull">
{formatDuration(duration)}
</span>
) : job.status === "queued" ? (
// Show waiting status for queued jobs
<span className="text-xs text-ink-dull">
Waiting...
</span>
) : (
// Show dash for failed/cancelled jobs
<span className="text-xs text-ink-dull"></span>
)}
</div>
{/* Created time */}
<div className="flex-shrink-0 w-24 text-right">
<span className="text-xs text-ink-dull">
{timeAgo(job.created_at)}
</span>
</div>
{/* Completed/Started time */}
<div className="flex-shrink-0 w-24 text-right">
<span className="text-xs text-ink-dull">
{job.status === "completed" && job.completed_at
? timeAgo(job.completed_at)
: job.status === "running" && job.started_at
? timeAgo(job.started_at)
: job.created_at
? timeAgo(job.created_at)
: "—"}
</span>
</div>
{/* Status */}
<div className="flex-shrink-0 w-20 text-right">
<span
className={clsx(
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
job.status === "running" && "bg-accent/10 text-accent",
job.status === "completed" && "bg-app-line/30 text-ink-dull",
job.status === "failed" && "bg-red-500/10 text-red-500",
job.status === "paused" && "bg-yellow-500/10 text-yellow-500",
job.status === "queued" && "bg-app-line/20 text-ink-dull"
)}
>
{job.status}
</span>
</div>
</div>
{/* Status */}
<div className="flex-shrink-0 w-20 text-right">
<span
className={clsx(
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
job.status === "running" &&
"bg-accent/10 text-accent",
job.status === "completed" &&
"bg-app-line/30 text-ink-dull",
job.status === "failed" &&
"bg-red-500/10 text-red-500",
job.status === "paused" &&
"bg-yellow-500/10 text-yellow-500",
job.status === "queued" &&
"bg-app-line/20 text-ink-dull",
)}
>
{job.status}
</span>
</div>
</div>
{/* Action button */}
{showActionButton && isHovered && (canPause || canResume) && (
<button
onClick={handleAction}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-app-box hover:bg-app-selected transition-colors"
title={canPause ? "Pause job" : "Resume job"}
>
{canPause ? (
<Pause size={12} weight="fill" className="text-ink" />
) : (
<Play size={12} weight="fill" className="text-ink" />
)}
</button>
)}
</div>
);
{/* Action buttons */}
{isHovered && (
<div className="flex items-center gap-1">
{showActionButton && (canPause || canResume) && (
<button
onClick={handleAction}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-app-box hover:bg-app-selected transition-colors"
title={canPause ? "Pause job" : "Resume job"}
>
{canPause ? (
<Pause size={12} weight="fill" className="text-ink" />
) : (
<Play size={12} weight="fill" className="text-ink" />
)}
</button>
)}
{canCancel && (
<button
onClick={handleCancel}
className="flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full bg-app-box hover:bg-red-500 transition-colors"
title="Cancel job"
>
<X size={12} weight="bold" className="text-ink hover:text-white" />
</button>
)}
</div>
)}
</div>
);
}

View File

@@ -6,151 +6,206 @@ import { useJobs } from "../hooks/useJobs";
import { JobRow } from "./JobRow";
export function JobsScreen() {
const navigate = useNavigate();
const { jobs, pause, resume } = useJobs();
const [showOnlyRunning, setShowOnlyRunning] = useState(false);
const navigate = useNavigate();
const { jobs, pause, resume, cancel } = useJobs();
const [showOnlyRunning, setShowOnlyRunning] = useState(false);
// Filter jobs based on toggle
const filteredJobs = showOnlyRunning
? jobs.filter(job => job.status === "running" || job.status === "paused")
: jobs;
// Filter jobs based on toggle
const filteredJobs = showOnlyRunning
? jobs.filter(
(job) => job.status === "running" || job.status === "paused",
)
: jobs;
// Group jobs by status
const runningJobs = filteredJobs.filter(j => j.status === "running");
const pausedJobs = filteredJobs.filter(j => j.status === "paused");
const queuedJobs = filteredJobs.filter(j => j.status === "queued");
const completedJobs = filteredJobs.filter(j => j.status === "completed");
const failedJobs = filteredJobs.filter(j => j.status === "failed");
// Group jobs by status
const runningJobs = filteredJobs.filter((j) => j.status === "running");
const pausedJobs = filteredJobs.filter((j) => j.status === "paused");
const queuedJobs = filteredJobs.filter((j) => j.status === "queued");
const completedJobs = filteredJobs.filter((j) => j.status === "completed");
const failedJobs = filteredJobs.filter((j) => j.status === "failed");
return (
<div className="flex flex-col h-screen bg-app">
{/* Header */}
<div className="sticky top-0 z-10 backdrop-blur-xl bg-app/80 border-b border-app-line">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-ink">Jobs</h1>
<div className="flex items-center gap-2 text-sm text-ink-dull">
<span>{jobs.length} total</span>
{runningJobs.length > 0 && (
<>
<span></span>
<span>{runningJobs.length} running</span>
</>
)}
</div>
</div>
return (
<div className="flex flex-col h-screen bg-app">
{/* Header */}
<div className="sticky top-0 z-10 backdrop-blur-xl bg-app/80 border-b border-app-line">
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-ink">Jobs</h1>
<div className="flex items-center gap-2 text-sm text-ink-dull">
<span>{jobs.length} total</span>
{runningJobs.length > 0 && (
<>
<span></span>
<span>{runningJobs.length} running</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
{/* Filter toggle */}
<TopBarButton
icon={FunnelSimple}
active={showOnlyRunning}
onClick={() => setShowOnlyRunning(!showOnlyRunning)}
title={showOnlyRunning ? "Show all jobs" : "Show only active jobs"}
/>
<div className="flex items-center gap-2">
{/* Filter toggle */}
<TopBarButton
icon={FunnelSimple}
active={showOnlyRunning}
onClick={() => setShowOnlyRunning(!showOnlyRunning)}
title={
showOnlyRunning
? "Show all jobs"
: "Show only active jobs"
}
/>
{/* Back button */}
<TopBarButton
icon={X}
onClick={() => navigate(-1)}
title="Go back"
/>
</div>
</div>
{/* Back button */}
<TopBarButton
icon={X}
onClick={() => navigate(-1)}
title="Go back"
/>
</div>
</div>
{/* Column headers */}
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-ink-dull uppercase tracking-wide bg-app-box/30 border-t border-app-line/30">
<div className="flex-shrink-0 w-10" /> {/* Icon spacer */}
<div className="flex-1 min-w-0 flex items-center gap-6">
<div className="flex-1">Name</div>
<div className="flex-shrink-0 w-32">Progress</div>
<div className="flex-shrink-0 w-20 text-right">Duration</div>
<div className="flex-shrink-0 w-24 text-right">Created</div>
<div className="flex-shrink-0 w-20 text-right">Status</div>
</div>
<div className="flex-shrink-0 w-6" /> {/* Action button spacer */}
</div>
</div>
{/* Column headers */}
<div className="flex items-center gap-4 px-4 py-2 text-xs font-medium text-ink-dull uppercase tracking-wide bg-app-box/30 border-t border-app-line/30">
<div className="flex-shrink-0 w-10" /> {/* Icon spacer */}
<div className="flex-1 min-w-0 flex items-center gap-6">
<div className="flex-1">Name</div>
<div className="flex-shrink-0 w-32">Duration</div>
<div className="flex-shrink-0 w-24 text-right">
Time
</div>
<div className="flex-shrink-0 w-20 text-right">
Status
</div>
</div>
<div className="flex-shrink-0 w-6" />{" "}
{/* Action button spacer */}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
{filteredJobs.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-sm text-ink-dull">No jobs found</p>
</div>
</div>
) : (
<div>
{/* Running Jobs */}
{runningJobs.length > 0 && (
<JobSection title="Running" count={runningJobs.length}>
{runningJobs.map(job => (
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
))}
</JobSection>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto">
{filteredJobs.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-sm text-ink-dull">
No jobs found
</p>
</div>
</div>
) : (
<div>
{/* Running Jobs */}
{runningJobs.length > 0 && (
<JobSection
title="Running"
count={runningJobs.length}
>
{runningJobs.map((job) => (
<JobRow
key={job.id}
job={job}
onPause={pause}
onResume={resume}
onCancel={cancel}
/>
))}
</JobSection>
)}
{/* Paused Jobs */}
{pausedJobs.length > 0 && (
<JobSection title="Paused" count={pausedJobs.length}>
{pausedJobs.map(job => (
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
))}
</JobSection>
)}
{/* Paused Jobs */}
{pausedJobs.length > 0 && (
<JobSection
title="Paused"
count={pausedJobs.length}
>
{pausedJobs.map((job) => (
<JobRow
key={job.id}
job={job}
onPause={pause}
onResume={resume}
onCancel={cancel}
/>
))}
</JobSection>
)}
{/* Queued Jobs */}
{queuedJobs.length > 0 && (
<JobSection title="Queued" count={queuedJobs.length}>
{queuedJobs.map(job => (
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
))}
</JobSection>
)}
{/* Queued Jobs */}
{queuedJobs.length > 0 && (
<JobSection
title="Queued"
count={queuedJobs.length}
>
{queuedJobs.map((job) => (
<JobRow
key={job.id}
job={job}
onPause={pause}
onResume={resume}
onCancel={cancel}
/>
))}
</JobSection>
)}
{/* Completed Jobs */}
{completedJobs.length > 0 && (
<JobSection title="Completed" count={completedJobs.length}>
{completedJobs.map(job => (
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
))}
</JobSection>
)}
{/* Completed Jobs */}
{completedJobs.length > 0 && (
<JobSection
title="Completed"
count={completedJobs.length}
>
{completedJobs.map((job) => (
<JobRow
key={job.id}
job={job}
onPause={pause}
onResume={resume}
onCancel={cancel}
/>
))}
</JobSection>
)}
{/* Failed Jobs */}
{failedJobs.length > 0 && (
<JobSection title="Failed" count={failedJobs.length}>
{failedJobs.map(job => (
<JobRow key={job.id} job={job} onPause={pause} onResume={resume} />
))}
</JobSection>
)}
</div>
)}
</div>
</div>
);
{/* Failed Jobs */}
{failedJobs.length > 0 && (
<JobSection
title="Failed"
count={failedJobs.length}
>
{failedJobs.map((job) => (
<JobRow
key={job.id}
job={job}
onPause={pause}
onResume={resume}
onCancel={cancel}
/>
))}
</JobSection>
)}
</div>
)}
</div>
</div>
);
}
interface JobSectionProps {
title: string;
count: number;
children: React.ReactNode;
title: string;
count: number;
children: React.ReactNode;
}
function JobSection({ title, count, children }: JobSectionProps) {
return (
<div>
<div className="sticky top-[73px] z-10 flex items-center gap-2 px-4 py-2 bg-app-box/50 backdrop-blur-sm border-b border-app-line/50">
<h2 className="text-xs font-semibold text-ink uppercase tracking-wide">
{title}
</h2>
<span className="text-xs text-ink-dull">({count})</span>
</div>
<div>
{children}
</div>
</div>
);
return (
<div>
<div className="sticky top-0 z-10 flex items-center gap-2 px-4 py-2 bg-app-box/50 backdrop-blur-sm border-b border-app-line/50">
<h2 className="text-xs font-semibold text-ink uppercase tracking-wide">
{title}
</h2>
<span className="text-xs text-ink-dull">({count})</span>
</div>
<div>{children}</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Pause, Play } from "@phosphor-icons/react";
import { Pause, Play, X } from "@phosphor-icons/react";
import clsx from "clsx";
import type { JobListItem } from "../types";
import {
@@ -15,9 +15,10 @@ interface JobCardProps {
job: JobListItem;
onPause?: (jobId: string) => void;
onResume?: (jobId: string) => void;
onCancel?: (jobId: string) => void;
}
export function JobCard({ job, onPause, onResume }: JobCardProps) {
export function JobCard({ job, onPause, onResume, onCancel }: JobCardProps) {
const [isHovered, setIsHovered] = useState(false);
const displayName = getJobDisplayName(job);
@@ -27,6 +28,7 @@ export function JobCard({ job, onPause, onResume }: JobCardProps) {
const showActionButton = job.status === "running" || job.status === "paused";
const canPause = job.status === "running" && onPause;
const canResume = job.status === "paused" && onResume;
const canCancel = (job.status === "running" || job.status === "paused") && onCancel;
const handleAction = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -37,6 +39,13 @@ export function JobCard({ job, onPause, onResume }: JobCardProps) {
}
};
const handleCancel = (e: React.MouseEvent) => {
e.stopPropagation();
if (canCancel) {
onCancel(job.id);
}
};
return (
<div
className="flex rounded-xl border border-app-line/30 bg-app-box overflow-hidden"
@@ -62,18 +71,31 @@ export function JobCard({ job, onPause, onResume }: JobCardProps) {
{statusBadge}
</span>
{showActionButton && isHovered && (canPause || canResume) && (
<button
onClick={handleAction}
className="flex-shrink-0 flex items-center justify-center w-4 h-4 rounded-full bg-app-hover hover:bg-app-selected transition-colors"
title={canPause ? "Pause job" : "Resume job"}
>
{canPause ? (
<Pause size={10} weight="fill" className="text-ink" />
) : (
<Play size={10} weight="fill" className="text-ink" />
{isHovered && (
<div className="flex items-center gap-1">
{showActionButton && (canPause || canResume) && (
<button
onClick={handleAction}
className="flex-shrink-0 flex items-center justify-center w-4 h-4 rounded-full bg-app-hover hover:bg-app-selected transition-colors"
title={canPause ? "Pause job" : "Resume job"}
>
{canPause ? (
<Pause size={10} weight="fill" className="text-ink" />
) : (
<Play size={10} weight="fill" className="text-ink" />
)}
</button>
)}
</button>
{canCancel && (
<button
onClick={handleCancel}
className="flex-shrink-0 flex items-center justify-center w-4 h-4 rounded-full bg-app-hover hover:bg-red-500 transition-colors"
title="Cancel job"
>
<X size={10} weight="bold" className="text-ink hover:text-white" />
</button>
)}
</div>
)}
</div>

View File

@@ -7,9 +7,10 @@ interface JobListProps {
jobs: JobListItem[];
onPause?: (jobId: string) => void;
onResume?: (jobId: string) => void;
onCancel?: (jobId: string) => void;
}
export function JobList({ jobs, onPause, onResume }: JobListProps) {
export function JobList({ jobs, onPause, onResume, onCancel }: JobListProps) {
if (jobs.length === 0) {
return <EmptyState />;
}
@@ -25,7 +26,7 @@ export function JobList({ jobs, onPause, onResume }: JobListProps) {
exit={{ opacity: 0, x: -10 }}
transition={{ duration: 0.15, ease: [0.25, 1, 0.5, 1] }}
>
<JobCard job={job} onPause={onPause} onResume={onResume} />
<JobCard job={job} onPause={onPause} onResume={onResume} onCancel={onCancel} />
</motion.div>
))}
</AnimatePresence>

View File

@@ -1,6 +1,11 @@
import { useState, useEffect, useRef, useMemo } from "react";
import { useLibraryQuery, useLibraryMutation, useSpacedriveClient } from "../../../context";
import type { JobListItem } from "../types";
import { sounds } from "@sd/assets/sounds";
// Global set to track which jobs have already played their completion sound
// This prevents multiple hook instances from playing the sound multiple times
const completedJobSounds = new Set<string>();
/**
* Unified hook for job management and counting.
@@ -20,6 +25,7 @@ export function useJobs() {
const pauseMutation = useLibraryMutation("jobs.pause");
const resumeMutation = useLibraryMutation("jobs.resume");
const cancelMutation = useLibraryMutation("jobs.cancel");
// Ref for stable refetch access
const refetchRef = useRef(refetch);
@@ -44,6 +50,16 @@ export function useJobs() {
if ("JobQueued" in event || "JobStarted" in event || "JobCompleted" in event ||
"JobFailed" in event || "JobPaused" in event || "JobResumed" in event ||
"JobCancelled" in event) {
if ("JobCompleted" in event) {
const jobId = event.JobCompleted?.job_id;
if (jobId && !completedJobSounds.has(jobId)) {
completedJobSounds.add(jobId);
sounds.jobDone();
// Clean up old entries after 5 seconds to prevent memory leak
setTimeout(() => completedJobSounds.delete(jobId), 5000);
}
}
refetchRef.current();
} else if ("JobProgress" in event) {
const progressData = event.JobProgress;
@@ -88,11 +104,36 @@ export function useJobs() {
}, [client]);
const pause = async (jobId: string) => {
await pauseMutation.mutateAsync({ job_id: jobId });
try {
const result = await pauseMutation.mutateAsync({ job_id: jobId });
if (!result.success) {
console.error("Failed to pause job:", jobId);
}
} catch (error) {
console.error("Failed to pause job:", error);
}
};
const resume = async (jobId: string) => {
await resumeMutation.mutateAsync({ job_id: jobId });
try {
const result = await resumeMutation.mutateAsync({ job_id: jobId });
if (!result.success) {
console.error("Failed to resume job:", jobId);
}
} catch (error) {
console.error("Failed to resume job:", error);
}
};
const cancel = async (jobId: string) => {
try {
const result = await cancelMutation.mutateAsync({ job_id: jobId });
if (!result.success) {
console.error("Failed to cancel job:", jobId);
}
} catch (error) {
console.error("Failed to cancel job:", error);
}
};
const runningCount = jobs.filter((j) => j.status === "running").length;
@@ -104,6 +145,7 @@ export function useJobs() {
hasRunningJobs: runningCount > 0,
pause,
resume,
cancel,
isLoading,
error,
};

View File

@@ -18,6 +18,7 @@ import {
Cube,
} from "@phosphor-icons/react";
import { VideoPlayer } from "./VideoPlayer";
import type { VideoControlsState, VideoControlsCallbacks } from "./VideoControls";
import { AudioPlayer } from "./AudioPlayer";
import { useZoomPan } from "./useZoomPan";
import { TextViewer } from "./TextViewer";
@@ -37,6 +38,9 @@ const MeshViewerUI = lazy(() =>
interface ContentRendererProps {
file: File;
onZoomChange?: (isZoomed: boolean) => void;
onVideoControlsStateChange?: (state: VideoControlsState) => void;
onShowVideoControlsChange?: (show: boolean) => void;
getVideoCallbacks?: (callbacks: VideoControlsCallbacks) => void;
}
function ImageRenderer({ file, onZoomChange }: ContentRendererProps) {
@@ -388,7 +392,7 @@ function ImageRenderer({ file, onZoomChange }: ContentRendererProps) {
);
}
function VideoRenderer({ file, onZoomChange }: ContentRendererProps) {
function VideoRenderer({ file, onZoomChange, onVideoControlsStateChange, onShowVideoControlsChange, getVideoCallbacks }: ContentRendererProps) {
const platform = usePlatform();
const [videoUrl, setVideoUrl] = useState<string | null>(null);
const [shouldLoadVideo, setShouldLoadVideo] = useState(false);
@@ -444,7 +448,14 @@ function VideoRenderer({ file, onZoomChange }: ContentRendererProps) {
}
return (
<VideoPlayer src={videoUrl} file={file} onZoomChange={onZoomChange} />
<VideoPlayer
src={videoUrl}
file={file}
onZoomChange={onZoomChange}
onControlsStateChange={onVideoControlsStateChange}
onShowControlsChange={onShowVideoControlsChange}
getCallbacks={getVideoCallbacks}
/>
);
}
@@ -614,7 +625,7 @@ function DefaultRenderer({ file }: ContentRendererProps) {
);
}
export function ContentRenderer({ file, onZoomChange }: ContentRendererProps) {
export function ContentRenderer({ file, onZoomChange, onVideoControlsStateChange, onShowVideoControlsChange, getVideoCallbacks }: ContentRendererProps) {
// Handle directories first
if (file.kind.type === "Directory") {
return (
@@ -639,7 +650,7 @@ export function ContentRenderer({ file, onZoomChange }: ContentRendererProps) {
case "image":
return <ImageRenderer file={file} onZoomChange={onZoomChange} />;
case "video":
return <VideoRenderer file={file} onZoomChange={onZoomChange} />;
return <VideoRenderer file={file} onZoomChange={onZoomChange} onVideoControlsStateChange={onVideoControlsStateChange} onShowVideoControlsChange={onShowVideoControlsChange} getVideoCallbacks={getVideoCallbacks} />;
case "audio":
return <AudioRenderer file={file} />;
case "mesh":

View File

@@ -1,12 +1,17 @@
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import { X, ArrowLeft, ArrowRight } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import type { File } from "@sd/ts-client";
import { useNormalizedQuery } from "../../context";
import { ContentRenderer } from "./ContentRenderer";
import {
VideoControls,
type VideoControlsState,
type VideoControlsCallbacks,
} from "./VideoControls";
import { TopBarPortal } from "../../TopBar";
import { getContentKind } from "../Explorer/utils";
import { useExplorer } from "../Explorer/context";
interface QuickPreviewFullscreenProps {
fileId: string;
@@ -35,23 +40,27 @@ export function QuickPreviewFullscreen({
}: QuickPreviewFullscreenProps) {
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
const [isZoomed, setIsZoomed] = useState(false);
const [videoControlsState, setVideoControlsState] =
useState<VideoControlsState | null>(null);
const [showVideoControls, setShowVideoControls] = useState(false);
const [videoCallbacks, setVideoCallbacks] =
useState<VideoControlsCallbacks | null>(null);
const { currentFiles } = useExplorer();
// Reset zoom when file changes
useEffect(() => {
setIsZoomed(false);
}, [fileId]);
const {
data: file,
isLoading,
error,
} = useNormalizedQuery<{ file_id: string }, File>({
wireMethod: "query:files.by_id",
input: { file_id: fileId },
resourceType: "file",
resourceId: fileId,
enabled: !!fileId && isOpen,
});
// Get file directly from currentFiles - instant, no network request
const file = useMemo(
() => currentFiles.find((f) => f.id === fileId) ?? null,
[currentFiles, fileId],
);
// No query needed - files are already loaded by the explorer views
const isLoading = false;
const error = null;
// Find portal target on mount
useEffect(() => {
@@ -107,11 +116,11 @@ export function QuickPreviewFullscreen({
transition={{ duration: 0.2 }}
className={`absolute inset-0 flex flex-col ${getBackgroundClass()}`}
>
{isLoading || !file ? (
{!file && isLoading ? (
<div className="flex h-full items-center justify-center text-ink">
<div className="animate-pulse">Loading...</div>
</div>
) : error ? (
) : !file && error ? (
<div className="flex h-full items-center justify-center text-red-400">
<div>
<div className="mb-2 text-lg font-medium">
@@ -120,6 +129,10 @@ export function QuickPreviewFullscreen({
<div className="text-sm">{error.message}</div>
</div>
</div>
) : !file ? (
<div className="flex h-full items-center justify-center text-ink-dull">
<div>File not found</div>
</div>
) : (
<>
{/* TopBar content via portal */}
@@ -174,14 +187,45 @@ export function QuickPreviewFullscreen({
style={{
paddingLeft: isZoomed ? 0 : sidebarWidth,
paddingRight: isZoomed ? 0 : inspectorWidth,
transition: "padding 0.3s ease-out",
}}
>
<ContentRenderer
file={file}
onZoomChange={setIsZoomed}
onVideoControlsStateChange={
setVideoControlsState
}
onShowVideoControlsChange={
setShowVideoControls
}
getVideoCallbacks={setVideoCallbacks}
/>
</div>
{/* Video Controls Overlay - fixed position, always uses sidebar/inspector padding */}
{videoControlsState &&
videoCallbacks &&
getContentKind(file) === "video" && (
<div
className="absolute inset-0"
style={{
paddingTop: "56px", // TopBar height
paddingBottom: "40px", // Footer height
pointerEvents: "none", // Let clicks through except on controls themselves
}}
>
<VideoControls
file={file}
state={videoControlsState}
callbacks={videoCallbacks}
showControls={showVideoControls}
sidebarWidth={sidebarWidth}
inspectorWidth={inspectorWidth}
/>
</div>
)}
{/* Footer with keyboard hints */}
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 py-3">
<div className="text-center text-xs text-white/50">

View File

@@ -7,6 +7,8 @@ interface TimelineScrubberProps {
hoverPercent: number;
mouseX: number;
duration: number;
sidebarWidth?: number;
inspectorWidth?: number;
}
/**
@@ -20,6 +22,8 @@ export const TimelineScrubber = memo(function TimelineScrubber({
hoverPercent,
mouseX,
duration,
sidebarWidth = 0,
inspectorWidth = 0,
}: TimelineScrubberProps) {
const { buildSidecarUrl } = useServer();
@@ -75,12 +79,15 @@ export const TimelineScrubber = memo(function TimelineScrubber({
const previewWidth = 160;
const previewHeight = 90;
// Position horizontally following mouse, clamped to screen bounds
// Position horizontally following mouse, clamped to controls bounds
// Adjust for sidebar offset and clamp within the controls area
const controlsWidth = window.innerWidth - sidebarWidth - inspectorWidth;
const mouseXRelativeToControls = mouseX - sidebarWidth;
const leftPosition = Math.max(
10,
Math.min(
mouseX - previewWidth / 2,
window.innerWidth - previewWidth - 10,
mouseXRelativeToControls - previewWidth / 2,
controlsWidth - previewWidth - 10,
),
);
@@ -89,10 +96,10 @@ export const TimelineScrubber = memo(function TimelineScrubber({
return (
<div
className="fixed z-50 pointer-events-none"
className="absolute z-50 pointer-events-none"
style={{
left: leftPosition,
bottom: 160, // Well above the timeline
bottom: 80, // Just above the timeline
width: previewWidth,
}}
>

View File

@@ -0,0 +1,288 @@
import {
Play,
Pause,
SpeakerHigh,
SpeakerSlash,
ArrowsOut,
ClosedCaptioning,
MagnifyingGlassPlus,
MagnifyingGlassMinus,
ArrowCounterClockwise,
Gear,
Repeat,
} from "@phosphor-icons/react";
import { motion, AnimatePresence } from "framer-motion";
import type { File } from "@sd/ts-client";
import { TimelineScrubber } from "./TimelineScrubber";
export interface VideoControlsState {
playing: boolean;
currentTime: number;
duration: number;
volume: number;
muted: boolean;
loop: boolean;
zoom: number;
subtitlesEnabled: boolean;
showSubtitleSettings: boolean;
seeking: boolean;
timelineHover: { percent: number; mouseX: number } | null;
}
export interface VideoControlsCallbacks {
onTogglePlay: () => void;
onSeek: (e: React.MouseEvent<HTMLDivElement>) => void;
onTimelineHover: (e: React.MouseEvent<HTMLDivElement>) => void;
onTimelineLeave: () => void;
onSeekingStart: () => void;
onSeekingEnd: () => void;
onVolumeChange: (volume: number) => void;
onMuteToggle: () => void;
onLoopToggle: () => void;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomReset: () => void;
onSubtitlesToggle: () => void;
onSubtitleSettingsToggle: () => void;
onFullscreenToggle: () => void;
onMouseMove: () => void;
}
interface VideoControlsProps {
file: File;
state: VideoControlsState;
callbacks: VideoControlsCallbacks;
showControls: boolean;
sidebarWidth?: number;
inspectorWidth?: number;
}
function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export function VideoControls({
file,
state,
callbacks,
showControls,
sidebarWidth = 0,
inspectorWidth = 0,
}: VideoControlsProps) {
const hasSubs = file.sidecars?.some(
(s) => s.kind === "transcript" && s.variant === "srt",
);
return (
<AnimatePresence>
{showControls && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute bottom-0 z-50 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-4 pt-16"
style={{
pointerEvents: "auto",
left: sidebarWidth,
right: inspectorWidth,
}}
onMouseMove={callbacks.onMouseMove}
>
{/* Timeline Scrubber Preview */}
{state.timelineHover && (
<TimelineScrubber
file={file}
hoverPercent={state.timelineHover.percent}
mouseX={state.timelineHover.mouseX}
duration={state.duration}
sidebarWidth={sidebarWidth}
inspectorWidth={inspectorWidth}
/>
)}
{/* Progress Bar with Thick Hover Area */}
<div
className="group mb-3 cursor-pointer relative py-2 -my-2"
onMouseDown={(e) => {
callbacks.onSeekingStart();
callbacks.onSeek(e);
}}
onMouseMove={(e) => {
if (state.seeking) {
callbacks.onSeek(e);
} else {
callbacks.onTimelineHover(e);
}
}}
onMouseEnter={callbacks.onTimelineHover}
onMouseUp={callbacks.onSeekingEnd}
onMouseLeave={callbacks.onTimelineLeave}
>
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/20 transition-all group-hover:h-1.5">
{/* Progress */}
<div
className="absolute left-0 top-0 h-full bg-accent transition-all"
style={{
width: `${(state.currentTime / state.duration) * 100}%`,
}}
/>
{/* Scrubber */}
<div
className="absolute top-1/2 -translate-y-1/2 transition-all"
style={{
left: `${(state.currentTime / state.duration) * 100}%`,
}}
>
<div className="size-3 -translate-x-1/2 rounded-full bg-accent opacity-0 shadow-lg transition-opacity group-hover:opacity-100" />
</div>
</div>
</div>
{/* Controls Bar */}
<div className="flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={callbacks.onTogglePlay}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
>
{state.playing ? (
<Pause size={20} weight="fill" />
) : (
<Play size={20} weight="fill" />
)}
</button>
{/* Loop */}
<button
onClick={callbacks.onLoopToggle}
className={`rounded-md p-2 transition-colors ${
state.loop
? "bg-accent/20 text-accent"
: "text-white hover:bg-white/10"
}`}
title="Loop (L)"
>
<Repeat size={20} weight="bold" />
</button>
{/* Time */}
<div className="text-sm font-medium text-white">
{formatTime(state.currentTime)} /{" "}
{formatTime(state.duration)}
</div>
<div className="flex-1" />
{/* Subtitles Controls */}
{hasSubs && (
<div className="flex items-center gap-1">
<button
onClick={callbacks.onSubtitlesToggle}
className={`rounded-md p-2 transition-colors ${
state.subtitlesEnabled
? "bg-accent/20 text-accent"
: "text-white hover:bg-white/10"
}`}
title="Toggle subtitles"
>
<ClosedCaptioning size={20} weight="fill" />
</button>
{state.subtitlesEnabled && (
<button
onClick={
callbacks.onSubtitleSettingsToggle
}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
title="Subtitle settings"
>
<Gear size={20} weight="fill" />
</button>
)}
</div>
)}
{/* Zoom Controls */}
<div className="flex items-center gap-1">
<button
onClick={callbacks.onZoomOut}
disabled={state.zoom <= 1}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10 disabled:opacity-30"
title="Zoom out (-)"
>
<MagnifyingGlassMinus size={18} weight="bold" />
</button>
<button
onClick={callbacks.onZoomIn}
disabled={state.zoom >= 5}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10 disabled:opacity-30"
title="Zoom in (+)"
>
<MagnifyingGlassPlus size={18} weight="bold" />
</button>
{state.zoom > 1 && (
<button
onClick={callbacks.onZoomReset}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
title="Reset zoom (0)"
>
<ArrowCounterClockwise
size={18}
weight="bold"
/>
</button>
)}
</div>
{/* Volume */}
<div className="group flex items-center gap-2">
<button
onClick={callbacks.onMuteToggle}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
>
{state.muted || state.volume === 0 ? (
<SpeakerSlash size={20} weight="fill" />
) : (
<SpeakerHigh size={20} weight="fill" />
)}
</button>
{/* Volume Slider */}
<div className="w-0 overflow-hidden transition-all group-hover:w-20">
<input
type="range"
min="0"
max="1"
step="0.01"
value={state.volume}
onChange={(e) =>
callbacks.onVolumeChange(
parseFloat(e.target.value),
)
}
className="h-1 w-full cursor-pointer appearance-none rounded-full bg-white/20 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
/>
</div>
</div>
{/* Fullscreen */}
<button
onClick={callbacks.onFullscreenToggle}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
>
<ArrowsOut size={20} weight="bold" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -1,38 +1,44 @@
import { useState, useRef, useEffect } from 'react';
import { Play, Pause, SpeakerHigh, SpeakerSlash, ArrowsOut, ClosedCaptioning, MagnifyingGlassPlus, MagnifyingGlassMinus, ArrowCounterClockwise, Gear, Repeat } from '@phosphor-icons/react';
import { motion, AnimatePresence } from 'framer-motion';
import type { File } from '@sd/ts-client';
import { Subtitles, type SubtitleSettings } from './Subtitles';
import { SubtitleSettingsMenu } from './SubtitleSettingsMenu';
import { useZoomPan } from './useZoomPan';
import { TimelineScrubber } from './TimelineScrubber';
import { useState, useRef, useEffect, useCallback } from "react";
import type { File } from "@sd/ts-client";
import { Subtitles, type SubtitleSettings } from "./Subtitles";
import { SubtitleSettingsMenu } from "./SubtitleSettingsMenu";
import { useZoomPan } from "./useZoomPan";
import type {
VideoControlsState,
VideoControlsCallbacks,
} from "./VideoControls";
interface VideoPlayerProps {
src: string;
file: File;
onZoomChange?: (isZoomed: boolean) => void;
onControlsStateChange?: (state: VideoControlsState) => void;
onShowControlsChange?: (show: boolean) => void;
getCallbacks?: (callbacks: VideoControlsCallbacks) => void;
}
function formatTime(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
export function VideoPlayer({
src,
file,
onZoomChange,
onControlsStateChange,
onShowControlsChange,
getCallbacks,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(() => {
const saved = localStorage.getItem("sd-video-volume");
return saved ? parseFloat(saved) : 1;
});
const [muted, setMuted] = useState(() => {
const saved = localStorage.getItem("sd-video-muted");
return saved === "true";
});
const [loop, setLoop] = useState(false);
const [showControls, setShowControls] = useState(true);
const [seeking, setSeeking] = useState(false);
@@ -40,20 +46,114 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
const [showSubtitleSettings, setShowSubtitleSettings] = useState(false);
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleSettings>({
fontSize: 1.5,
position: 'bottom',
backgroundOpacity: 0.9
position: "bottom",
backgroundOpacity: 0.9,
});
const [timelineHover, setTimelineHover] = useState<{ percent: number; mouseX: number } | null>(null);
const hideControlsTimeout = useRef<ReturnType<typeof setTimeout>>();
const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } = useZoomPan(videoContainerRef);
const [timelineHover, setTimelineHover] = useState<{
percent: number;
mouseX: number;
} | null>(null);
const hideControlsTimeout = useRef<number | undefined>(undefined);
const { zoom, zoomIn, zoomOut, reset, isZoomed, transform } =
useZoomPan(videoContainerRef);
// Expose controls state to parent
useEffect(() => {
onControlsStateChange?.({
playing,
currentTime,
duration,
volume,
muted,
loop,
zoom,
subtitlesEnabled,
showSubtitleSettings,
seeking,
timelineHover,
});
}, [
playing,
currentTime,
duration,
volume,
muted,
loop,
zoom,
subtitlesEnabled,
showSubtitleSettings,
seeking,
timelineHover,
onControlsStateChange,
]);
// Expose showControls state to parent
useEffect(() => {
onShowControlsChange?.(showControls);
}, [showControls, onShowControlsChange]);
// Notify parent of zoom state changes
useEffect(() => {
onZoomChange?.(isZoomed);
}, [isZoomed, onZoomChange]);
// Show controls on mouse move, hide after 3s of inactivity
const handleMouseMove = () => {
const togglePlay = useCallback(() => {
if (!videoRef.current) return;
if (playing) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}, [playing]);
const handleSeek = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!videoRef.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
videoRef.current.currentTime = percent * duration;
},
[duration],
);
const handleTimelineHover = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
setTimelineHover({ percent, mouseX: e.clientX });
},
[],
);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
containerRef.current.requestFullscreen();
}
}, []);
const handleTimelineLeave = useCallback(() => {
setSeeking(false);
setTimelineHover(null);
}, []);
const handleSeekingStart = useCallback(() => setSeeking(true), []);
const handleSeekingEnd = useCallback(() => setSeeking(false), []);
const handleMuteToggle = useCallback(() => setMuted((m) => !m), []);
const handleLoopToggle = useCallback(() => setLoop((l) => !l), []);
const handleSubtitlesToggle = useCallback(
() => setSubtitlesEnabled((s) => !s),
[],
);
const handleSubtitleSettingsToggle = useCallback(
() => setShowSubtitleSettings((s) => !s),
[],
);
// Show controls on mouse move, hide after 1s of inactivity
const handleMouseMove = useCallback(() => {
setShowControls(true);
if (hideControlsTimeout.current) {
clearTimeout(hideControlsTimeout.current);
@@ -61,9 +161,48 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
if (playing) {
hideControlsTimeout.current = setTimeout(() => {
setShowControls(false);
}, 3000);
}, 1000);
}
};
}, [playing]);
// Provide callbacks to parent
useEffect(() => {
getCallbacks?.({
onTogglePlay: togglePlay,
onSeek: handleSeek,
onTimelineHover: handleTimelineHover,
onTimelineLeave: handleTimelineLeave,
onSeekingStart: handleSeekingStart,
onSeekingEnd: handleSeekingEnd,
onVolumeChange: setVolume,
onMuteToggle: handleMuteToggle,
onLoopToggle: handleLoopToggle,
onZoomIn: zoomIn,
onZoomOut: zoomOut,
onZoomReset: reset,
onSubtitlesToggle: handleSubtitlesToggle,
onSubtitleSettingsToggle: handleSubtitleSettingsToggle,
onFullscreenToggle: toggleFullscreen,
onMouseMove: handleMouseMove,
});
}, [
togglePlay,
handleSeek,
handleTimelineHover,
handleTimelineLeave,
handleSeekingStart,
handleSeekingEnd,
handleMuteToggle,
handleLoopToggle,
handleSubtitlesToggle,
handleSubtitleSettingsToggle,
toggleFullscreen,
handleMouseMove,
zoomIn,
zoomOut,
reset,
getCallbacks,
]);
// Keyboard shortcuts
useEffect(() => {
@@ -71,61 +210,73 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
if (!videoRef.current) return;
switch (e.code) {
case 'Space':
case "Space":
e.preventDefault();
togglePlay();
break;
case 'ArrowLeft':
case "ArrowLeft":
e.preventDefault();
videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime - 5);
videoRef.current.currentTime = Math.max(
0,
videoRef.current.currentTime - 5,
);
break;
case 'ArrowRight':
case "ArrowRight":
e.preventDefault();
videoRef.current.currentTime = Math.min(
duration,
videoRef.current.currentTime + 5
videoRef.current.currentTime + 5,
);
break;
case 'ArrowUp':
case "ArrowUp":
e.preventDefault();
setVolume((v) => Math.min(1, v + 0.1));
break;
case 'ArrowDown':
case "ArrowDown":
e.preventDefault();
setVolume((v) => Math.max(0, v - 0.1));
break;
case 'KeyM':
case "KeyM":
e.preventDefault();
setMuted((m) => !m);
handleMuteToggle();
break;
case 'KeyF':
case "KeyF":
e.preventDefault();
toggleFullscreen();
break;
case 'KeyC':
case "KeyC":
e.preventDefault();
setSubtitlesEnabled((s) => !s);
handleSubtitlesToggle();
break;
case 'KeyL':
case "KeyL":
e.preventDefault();
setLoop((l) => !l);
handleLoopToggle();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [duration, playing]);
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
duration,
togglePlay,
toggleFullscreen,
handleMuteToggle,
handleSubtitlesToggle,
handleLoopToggle,
]);
// Sync video element state
// Sync video element state and persist to localStorage
useEffect(() => {
if (!videoRef.current) return;
videoRef.current.volume = volume;
localStorage.setItem("sd-video-volume", volume.toString());
}, [volume]);
useEffect(() => {
if (!videoRef.current) return;
videoRef.current.muted = muted;
localStorage.setItem("sd-video-muted", muted.toString());
}, [muted]);
useEffect(() => {
@@ -133,45 +284,11 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
videoRef.current.loop = loop;
}, [loop]);
const togglePlay = () => {
if (!videoRef.current) return;
if (playing) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
};
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (!videoRef.current) return;
const rect = e.currentTarget.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
videoRef.current.currentTime = percent * duration;
};
const handleTimelineHover = (e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
setTimelineHover({ percent, mouseX: e.clientX });
};
const toggleFullscreen = () => {
if (!containerRef.current) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
containerRef.current.requestFullscreen();
}
};
const hasSubs = file.sidecars?.some(s => s.kind === 'transcript' && s.variant === 'srt');
return (
<div
ref={containerRef}
className="relative flex h-full w-full items-center justify-center bg-black"
onMouseMove={handleMouseMove}
onMouseLeave={() => playing && setShowControls(false)}
>
{/* Zoom level indicator */}
{zoom > 1 && (
@@ -183,9 +300,12 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
{/* Video container with zoom/pan */}
<div
ref={videoContainerRef}
className={`relative flex h-full w-full items-center justify-center ${isZoomed ? 'overflow-visible' : 'overflow-hidden'}`}
className={`relative flex h-full w-full items-center justify-center ${isZoomed ? "overflow-visible" : "overflow-hidden"}`}
>
<div style={transform} className="flex items-center justify-center">
<div
style={transform}
className="flex items-center justify-center"
>
<video
ref={videoRef}
src={src}
@@ -194,16 +314,29 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
className="max-h-screen max-w-screen"
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onTimeUpdate={(e) => !seeking && setCurrentTime(e.currentTarget.currentTime)}
onDurationChange={(e) => setDuration(e.currentTarget.duration)}
onLoadedMetadata={(e) => setDuration(e.currentTarget.duration)}
onTimeUpdate={(e) =>
!seeking &&
setCurrentTime(e.currentTarget.currentTime)
}
onDurationChange={(e) =>
setDuration(e.currentTarget.duration)
}
onLoadedMetadata={(e) =>
setDuration(e.currentTarget.duration)
}
/>
</div>
</div>
{/* Subtitles */}
{subtitlesEnabled && (
<Subtitles file={file} videoElement={videoRef.current} settings={subtitleSettings} />
<div className="absolute inset-0 z-10 pointer-events-none">
<Subtitles
file={file}
videoElement={videoRef.current}
settings={subtitleSettings}
/>
</div>
)}
{/* Subtitle Settings Menu */}
@@ -213,188 +346,6 @@ export function VideoPlayer({ src, file, onZoomChange }: VideoPlayerProps) {
onSettingsChange={setSubtitleSettings}
onClose={() => setShowSubtitleSettings(false)}
/>
{/* Controls Overlay */}
<AnimatePresence>
{showControls && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-4 pb-4 pt-16"
>
{/* Timeline Scrubber Preview */}
{timelineHover && (
<TimelineScrubber
file={file}
hoverPercent={timelineHover.percent}
mouseX={timelineHover.mouseX}
duration={duration}
/>
)}
{/* Progress Bar with Thick Hover Area */}
<div
className="group mb-3 cursor-pointer relative py-2 -my-2"
onMouseDown={(e) => {
setSeeking(true);
handleSeek(e);
}}
onMouseMove={(e) => {
if (seeking) {
handleSeek(e);
} else {
handleTimelineHover(e);
}
}}
onMouseEnter={handleTimelineHover}
onMouseUp={() => setSeeking(false)}
onMouseLeave={() => {
setSeeking(false);
setTimelineHover(null);
}}
>
<div className="relative h-1 w-full overflow-hidden rounded-full bg-white/20 transition-all group-hover:h-1.5">
{/* Progress */}
<div
className="absolute left-0 top-0 h-full bg-accent transition-all"
style={{ width: `${(currentTime / duration) * 100}%` }}
/>
{/* Scrubber */}
<div
className="absolute top-1/2 -translate-y-1/2 transition-all"
style={{ left: `${(currentTime / duration) * 100}%` }}
>
<div className="size-3 -translate-x-1/2 rounded-full bg-accent opacity-0 shadow-lg transition-opacity group-hover:opacity-100" />
</div>
</div>
</div>
{/* Controls Bar */}
<div className="flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={togglePlay}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
>
{playing ? <Pause size={20} weight="fill" /> : <Play size={20} weight="fill" />}
</button>
{/* Loop */}
<button
onClick={() => setLoop(!loop)}
className={`rounded-md p-2 transition-colors ${
loop
? 'bg-accent/20 text-accent'
: 'text-white hover:bg-white/10'
}`}
title="Loop (L)"
>
<Repeat size={20} weight="bold" />
</button>
{/* Time */}
<div className="text-sm font-medium text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
<div className="flex-1" />
{/* Subtitles Controls */}
{hasSubs && (
<div className="flex items-center gap-1">
<button
onClick={() => setSubtitlesEnabled(!subtitlesEnabled)}
className={`rounded-md p-2 transition-colors ${
subtitlesEnabled
? 'bg-accent/20 text-accent'
: 'text-white hover:bg-white/10'
}`}
title="Toggle subtitles"
>
<ClosedCaptioning size={20} weight="fill" />
</button>
{subtitlesEnabled && (
<button
onClick={() => setShowSubtitleSettings(!showSubtitleSettings)}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
title="Subtitle settings"
>
<Gear size={20} weight="fill" />
</button>
)}
</div>
)}
{/* Zoom Controls */}
<div className="flex items-center gap-1">
<button
onClick={zoomOut}
disabled={zoom <= 1}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10 disabled:opacity-30"
title="Zoom out (-)"
>
<MagnifyingGlassMinus size={18} weight="bold" />
</button>
<button
onClick={zoomIn}
disabled={zoom >= 5}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10 disabled:opacity-30"
title="Zoom in (+)"
>
<MagnifyingGlassPlus size={18} weight="bold" />
</button>
{zoom > 1 && (
<button
onClick={reset}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
title="Reset zoom (0)"
>
<ArrowCounterClockwise size={18} weight="bold" />
</button>
)}
</div>
{/* Volume */}
<div className="group flex items-center gap-2">
<button
onClick={() => setMuted(!muted)}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
>
{muted || volume === 0 ? (
<SpeakerSlash size={20} weight="fill" />
) : (
<SpeakerHigh size={20} weight="fill" />
)}
</button>
{/* Volume Slider */}
<div className="w-0 overflow-hidden transition-all group-hover:w-20">
<input
type="range"
min="0"
max="1"
step="0.01"
value={volume}
onChange={(e) => setVolume(parseFloat(e.target.value))}
className="h-1 w-full cursor-pointer appearance-none rounded-full bg-white/20 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
/>
</div>
</div>
{/* Fullscreen */}
<button
onClick={toggleFullscreen}
className="rounded-md p-2 text-white transition-colors hover:bg-white/10"
>
<ArrowsOut size={20} weight="bold" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, RefObject } from 'react';
import { useState, useCallback, useEffect, RefObject } from "react";
interface UseZoomPanOptions {
minZoom?: number;
@@ -8,9 +8,9 @@ interface UseZoomPanOptions {
export function useZoomPan(
containerRef: RefObject<HTMLElement>,
options: UseZoomPanOptions = {}
options: UseZoomPanOptions = {},
) {
const { minZoom = 1, maxZoom = 5, zoomStep = 0.2 } = options;
const { minZoom = 1, maxZoom = 5, zoomStep = 0.1 } = options;
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
@@ -46,17 +46,25 @@ export function useZoomPan(
const handleWheel = (e: WheelEvent) => {
// Only zoom if not scrolling controls or other UI
if ((e.target as HTMLElement).closest('input, button, [role="slider"]')) {
if (
(e.target as HTMLElement).closest(
'input, button, [role="slider"]',
)
) {
return;
}
e.preventDefault();
const delta = -e.deltaY;
const zoomChange = delta > 0 ? zoomStep : -zoomStep;
// Scale the wheel delta proportionally (typical deltaY is ~100 per notch)
// Divide by 500 for responsive zoom: 100 deltaY = 0.2 zoom change
const zoomChange = -e.deltaY / 500;
setZoom((z) => {
const newZoom = Math.max(minZoom, Math.min(maxZoom, z + zoomChange));
const newZoom = Math.max(
minZoom,
Math.min(maxZoom, z + zoomChange),
);
// Reset pan when zooming back to 1x
if (newZoom === 1) {
setPan({ x: 0, y: 0 });
@@ -65,9 +73,9 @@ export function useZoomPan(
});
};
container.addEventListener('wheel', handleWheel, { passive: false });
return () => container.removeEventListener('wheel', handleWheel);
}, [containerRef, minZoom, maxZoom, zoomStep]);
container.addEventListener("wheel", handleWheel, { passive: false });
return () => container.removeEventListener("wheel", handleWheel);
}, [containerRef, minZoom, maxZoom]);
// Pan with mouse drag (only when zoomed in)
useEffect(() => {
@@ -76,13 +84,17 @@ export function useZoomPan(
const handleMouseDown = (e: MouseEvent) => {
// Don't pan if clicking on controls
if ((e.target as HTMLElement).closest('button, input, [role="slider"]')) {
if (
(e.target as HTMLElement).closest(
'button, input, [role="slider"]',
)
) {
return;
}
setIsDragging(true);
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
container.style.cursor = 'grabbing';
container.style.cursor = "grabbing";
};
const handleMouseMove = (e: MouseEvent) => {
@@ -90,31 +102,31 @@ export function useZoomPan(
setPan({
x: e.clientX - dragStart.x,
y: e.clientY - dragStart.y
y: e.clientY - dragStart.y,
});
};
const handleMouseUp = () => {
setIsDragging(false);
if (zoom > 1) {
container.style.cursor = 'grab';
container.style.cursor = "grab";
} else {
container.style.cursor = 'default';
container.style.cursor = "default";
}
};
container.addEventListener('mousedown', handleMouseDown);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
container.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
// Set cursor
container.style.cursor = zoom > 1 ? 'grab' : 'default';
container.style.cursor = zoom > 1 ? "grab" : "default";
return () => {
container.removeEventListener('mousedown', handleMouseDown);
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
container.style.cursor = 'default';
container.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
container.style.cursor = "default";
};
}, [containerRef, zoom, pan, isDragging, dragStart]);
@@ -122,24 +134,24 @@ export function useZoomPan(
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't interfere with inputs
if ((e.target as HTMLElement).tagName === 'INPUT') {
if ((e.target as HTMLElement).tagName === "INPUT") {
return;
}
if (e.key === '=' || e.key === '+') {
if (e.key === "=" || e.key === "+") {
e.preventDefault();
zoomIn();
} else if (e.key === '-' || e.key === '_') {
} else if (e.key === "-" || e.key === "_") {
e.preventDefault();
zoomOut();
} else if (e.key === '0') {
} else if (e.key === "0") {
e.preventDefault();
reset();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [zoomIn, zoomOut, reset]);
return {
@@ -151,7 +163,7 @@ export function useZoomPan(
isZoomed: zoom > 1,
transform: {
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transition: isDragging ? 'none' : 'transform 0.1s ease-out'
}
transition: isDragging ? "none" : "transform 0.05s ease-out",
},
};
}

View File

@@ -18,7 +18,7 @@ export function DevicesGroup({
sortableAttributes,
sortableListeners,
}: DevicesGroupProps) {
const { navigateToView } = useExplorer();
const { navigateToView, loadPreferencesForSpaceItem } = useExplorer();
// Use normalized query for automatic updates when device events are emitted
const { data: devices, isLoading } = useNormalizedQuery<
@@ -53,6 +53,18 @@ export function DevicesGroup({
onClick: async () => {
await revokeDevice.mutateAsync({
device_id: device.id,
remove_from_library: false, // Keep device in library
});
},
variant: "default" as const,
},
{
icon: Trash,
label: "Remove Device Completely",
onClick: async () => {
await revokeDevice.mutateAsync({
device_id: device.id,
remove_from_library: true, // Remove from library too
});
},
variant: "danger" as const,
@@ -106,7 +118,10 @@ export function DevicesGroup({
item={deviceItem as any}
customIcon={getDeviceIcon(device)}
customLabel={device.name}
onClick={() => navigateToView("device", device.id)}
onClick={() => {
loadPreferencesForSpaceItem(`device:${device.id}`);
navigateToView("device", device.id);
}}
onContextMenu={handleDeviceContextMenu(device)}
allowInsertion={false}
isLastItem={index === devices.length - 1}

View File

@@ -1,216 +1,219 @@
import { useNavigate, useLocation } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import clsx from "clsx";
import { useState, useEffect } from "react";
import {
House,
Clock,
Heart,
Folder,
HardDrive,
Tag as TagIcon,
FolderOpen,
MagnifyingGlass,
Trash,
Database,
Folders,
} from "@phosphor-icons/react";
import { Location } from "@sd/assets/icons";
import type {
SpaceItem as SpaceItemType,
ItemType,
File,
} from "@sd/ts-client";
import type { SpaceItem as SpaceItemType } from "@sd/ts-client";
import { Thumb } from "../Explorer/File/Thumb";
import { useContextMenu } from "../../hooks/useContextMenu";
import { usePlatform } from "../../platform";
import { useLibraryMutation } from "../../context";
import { useDroppable, useDndContext } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useExplorer } from "../Explorer/context";
interface SpaceItemProps {
item: SpaceItemType;
/** Optional component to render on the right side (e.g., badges, status indicators) */
rightComponent?: React.ReactNode;
/** Optional className to override default styling */
className?: string;
/** Optional icon weight (default: "bold") */
iconWeight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone";
/** Optional onClick handler to override default navigation */
onClick?: () => void;
/** Volume data for constructing explorer path */
volumeData?: { device_slug: string; mount_path: string };
/** Optional custom icon (as image path) to override default icon */
customIcon?: string;
/** Optional custom label to override automatic label detection */
customLabel?: string;
/** Whether this is the last item in the list (for showing bottom insertion line) */
isLastItem?: boolean;
/** Whether this item supports insertion (reordering) - false for system groups */
allowInsertion?: boolean;
/** The space ID this item belongs to (for adding items on insertion) */
spaceId?: string;
/** The group ID this item belongs to (for adding items on insertion) */
groupId?: string | null;
/** Whether this item is sortable (can be reordered) */
sortable?: boolean;
/** Optional onContextMenu handler to override default context menu */
import {
resolveItemMetadata,
isRawLocation,
type IconData,
} from "./hooks/spaceItemUtils";
import { useSpaceItemActive } from "./hooks/useSpaceItemActive";
import { useSpaceItemDropZones } from "./hooks/useSpaceItemDropZones";
import { useSpaceItemContextMenu } from "./hooks/useSpaceItemContextMenu";
import { useExplorer, getSpaceItemKeyFromRoute } from "../Explorer/context";
// Overrides for customizing item appearance and behavior
export interface SpaceItemOverrides {
label?: string;
icon?: string;
onClick?: (e?: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
}
function getItemIcon(itemType: ItemType): any {
if (itemType === "Overview") return { type: "component", icon: House };
if (itemType === "Recents") return { type: "component", icon: Clock };
if (itemType === "Favorites") return { type: "component", icon: Heart };
if (itemType === "FileKinds") return { type: "component", icon: Folders };
if (typeof itemType === "object" && "Location" in itemType)
return { type: "image", icon: Location };
if (typeof itemType === "object" && "Volume" in itemType)
return { type: "component", icon: HardDrive };
if (typeof itemType === "object" && "Tag" in itemType)
return { type: "component", icon: TagIcon };
if (typeof itemType === "object" && "Path" in itemType)
return { type: "image", icon: Location };
return { type: "image", icon: Location };
export interface SpaceItemProps {
item: SpaceItemType;
spaceId?: string;
groupId?: string | null;
// Behavior flags
sortable?: boolean;
allowInsertion?: boolean;
isLastItem?: boolean;
// Overrides
overrides?: SpaceItemOverrides;
rightComponent?: React.ReactNode;
// Legacy props (for backwards compatibility during migration)
volumeData?: { device_slug: string; mount_path: string };
customIcon?: string;
customLabel?: string;
onClick?: (e?: React.MouseEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
className?: string;
}
function getItemLabel(itemType: ItemType): string {
if (itemType === "Overview") return "Overview";
if (itemType === "Recents") return "Recents";
if (itemType === "Favorites") return "Favorites";
if (itemType === "FileKinds") return "File Kinds";
if (typeof itemType === "object" && "Location" in itemType) {
return itemType.Location.name || "Unnamed Location";
// Icon component that handles both component icons and image icons
function ItemIcon({ icon }: { icon: IconData }) {
if (icon.type === "image") {
return <img src={icon.icon} alt="" className="size-4 shrink-0" />;
}
if (typeof itemType === "object" && "Volume" in itemType) {
return itemType.Volume.name || "Unnamed Volume";
}
if (typeof itemType === "object" && "Tag" in itemType) {
return itemType.Tag.name || "Unnamed Tag";
}
if (typeof itemType === "object" && "Path" in itemType) {
// Extract name from path
const path = itemType.Path.sd_path;
if (typeof path === "object" && "Physical" in path) {
const parts = path.Physical.path.split("/");
return parts[parts.length - 1] || "Path";
}
return "Path";
}
return "Unknown";
const IconComponent = icon.icon;
return (
<span className="shrink-0">
<IconComponent size={16} weight="bold" />
</span>
);
}
function getItemPath(
itemType: ItemType,
volumeData?: { device_slug: string; mount_path: string },
resolvedFile?: File,
itemSdPath?: any
): string | null {
if (itemType === "Overview") return "/";
if (itemType === "Recents") return "/recents";
if (itemType === "Favorites") return "/favorites";
if (itemType === "FileKinds") return "/file-kinds";
if (typeof itemType === "object" && "Location" in itemType) {
// Use explorer route with location's SD path (passed from item.sd_path)
if (itemSdPath) {
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemSdPath))}`;
}
return null;
// Insertion line indicator
function InsertionLine({ visible }: { visible: boolean }) {
if (!visible) return null;
return (
<div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
);
}
// Bottom insertion line (for last items)
function BottomInsertionLine({ visible }: { visible: boolean }) {
if (!visible) return null;
return (
<div className="absolute -bottom-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
);
}
// Drop highlight ring for drop-into targets
function DropHighlight({ visible }: { visible: boolean }) {
if (!visible) return null;
return (
<div className="absolute inset-0 rounded-md ring-2 ring-accent/50 ring-inset pointer-events-none z-10" />
);
}
// Drop zone overlays (invisible hit areas)
interface DropZoneOverlaysProps {
isDropTarget: boolean;
setTopRef: (node: HTMLElement | null) => void;
setBottomRef: (node: HTMLElement | null) => void;
setMiddleRef: (node: HTMLElement | null) => void;
}
function DropZoneOverlays({
isDropTarget,
setTopRef,
setBottomRef,
setMiddleRef,
}: DropZoneOverlaysProps) {
if (isDropTarget) {
return (
<>
{/* Top zone - insertion above */}
<div
ref={setTopRef}
className="absolute left-0 right-0 pointer-events-none"
style={{
top: "-2px",
height: "calc(25% + 2px)",
zIndex: 10,
}}
/>
{/* Middle zone - drop into folder */}
<div
ref={setMiddleRef}
className="absolute left-0 right-0 pointer-events-none"
style={{ top: "25%", height: "50%", zIndex: 11 }}
/>
{/* Bottom zone - insertion below */}
<div
ref={setBottomRef}
className="absolute left-0 right-0 pointer-events-none"
style={{
bottom: "-2px",
height: "calc(25% + 2px)",
zIndex: 10,
}}
/>
</>
);
}
if (typeof itemType === "object" && "Volume" in itemType) {
// Navigate to explorer with volume's root path
if (volumeData) {
const sdPath = {
Physical: {
device_slug: volumeData.device_slug,
path: volumeData.mount_path || "/",
},
};
return `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}`;
}
return null;
}
if (typeof itemType === "object" && "Tag" in itemType)
return `/tag/${itemType.Tag.tag_id}`;
if (typeof itemType === "object" && "Path" in itemType) {
// Navigate to explorer with the SD path
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`;
}
return null;
return (
<>
{/* Top zone - insertion above */}
<div
ref={setTopRef}
className="absolute left-0 right-0 pointer-events-none"
style={{ top: "-2px", height: "calc(50% + 2px)", zIndex: 10 }}
/>
{/* Bottom zone - insertion below */}
<div
ref={setBottomRef}
className="absolute left-0 right-0 pointer-events-none"
style={{
bottom: "-2px",
height: "calc(50% + 2px)",
zIndex: 10,
}}
/>
</>
);
}
export function SpaceItem({
item,
rightComponent,
className,
iconWeight = "bold",
onClick,
volumeData,
customIcon,
customLabel,
isLastItem = false,
allowInsertion = true,
spaceId,
groupId,
sortable = false,
onContextMenu,
allowInsertion = true,
isLastItem = false,
overrides,
rightComponent,
// Legacy props
volumeData,
customIcon,
customLabel,
onClick: legacyOnClick,
onContextMenu: legacyOnContextMenu,
className,
}: SpaceItemProps) {
const navigate = useNavigate();
const location = useLocation();
const platform = usePlatform();
const deleteItem = useLibraryMutation("spaces.delete_item");
const indexVolume = useLibraryMutation("volumes.index");
const { active } = useDndContext();
const { currentView, currentPath } = useExplorer();
// Disable insertion drop zones when dragging groups or space items (they have 'label' in their data)
const isDraggingSortableItem = active?.data?.current?.label != null;
const { loadPreferencesForSpaceItem } = useExplorer();
// Check if this is a raw location object (has 'name' and 'sd_path' but no 'item_type')
const isRawLocation =
"name" in item && "sd_path" in item && !item.item_type;
// Merge legacy props into overrides
const effectiveOverrides: SpaceItemOverrides = {
...overrides,
label: overrides?.label ?? customLabel,
icon: overrides?.icon ?? customIcon,
onClick: overrides?.onClick ?? legacyOnClick,
onContextMenu: overrides?.onContextMenu ?? legacyOnContextMenu,
};
// Check if we have a resolved file
const resolvedFile = item.resolved_file as File | undefined;
let iconData, label, path;
if (isRawLocation) {
// Handle raw location object
iconData = { type: "image", icon: Location };
label = (item as any).name || "Unnamed Location";
// Use explorer path with the location's sd_path
const sdPath = (item as any).sd_path;
path = sdPath ? `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}` : null;
} else {
// Handle proper SpaceItem
iconData = getItemIcon(item.item_type);
// Use resolved file name if available, otherwise parse from item_type
label = resolvedFile?.name || getItemLabel(item.item_type);
// Pass item.sd_path for locations (available on SpaceItem objects)
path = getItemPath(item.item_type, volumeData, resolvedFile, (item as any).sd_path);
}
// Override with custom icon if provided
if (customIcon) {
iconData = { type: "image", icon: customIcon };
}
// Override with custom label if provided
if (customLabel) {
label = customLabel;
}
// Sortable hook (for reordering) - must be after label is defined
const sortableProps = useSortable({
id: item.id,
disabled: !sortable,
data: {
label: label,
},
// Resolve metadata (icon, label, path)
const { icon, label, path } = resolveItemMetadata(item, {
volumeData,
customIcon: effectiveOverrides.icon,
customLabel: effectiveOverrides.label,
});
// Get resolved file for thumbnail rendering
const resolvedFile = isRawLocation(item)
? undefined
: (item as SpaceItemType).resolved_file;
// Active state detection
const isActive = useSpaceItemActive({
item: item as SpaceItemType,
path,
hasCustomOnClick: !!effectiveOverrides.onClick,
});
// Drop zone management
const dropZones = useSpaceItemDropZones({
item: item as SpaceItemType,
allowInsertion,
spaceId,
groupId,
volumeData,
});
// Context menu
const contextMenu = useSpaceItemContextMenu({
item: item as SpaceItemType,
path,
spaceId,
});
// Sortable drag/drop
const {
attributes: sortableAttributes,
listeners: sortableListeners,
@@ -218,194 +221,37 @@ export function SpaceItem({
transform,
transition,
isDragging: isSortableDragging,
} = sortableProps;
} = useSortable({
id: (item as SpaceItemType).id,
disabled: !sortable,
data: { label },
});
const style = sortable ? {
transform: CSS.Transform.toString(transform),
transition,
} : undefined;
// Check if this item is active
const isActive = (() => {
// For items with custom onClick (like virtual device views), ONLY check virtual view state
// These items represent virtual views and should never use path-based matching
if (onClick) {
if (currentView) {
// Check if this item matches the current virtual view
// Convert both IDs to strings for comparison since URL params are always strings
const itemIdStr = String(item.id);
const isViewMatch = currentView.view === "device" && currentView.id === itemIdStr;
console.log("[SpaceItem] Virtual view check (with onClick):", {
label: customLabel || label,
currentView,
itemId: item.id,
itemIdStr,
isViewMatch,
});
return isViewMatch;
const style = sortable
? {
transform: CSS.Transform.toString(transform),
transition,
}
: undefined;
// No current view active - virtual items are never active on regular routes
console.log("[SpaceItem] Virtual item on regular route:", {
label: customLabel || label,
hasOnClick: true,
currentView: null,
});
return false;
}
// Check virtual view state for items without custom onClick
if (currentView) {
const itemIdStr = String(item.id);
const isViewMatch = currentView.view === "device" && currentView.id === itemIdStr;
console.log("[SpaceItem] Virtual view check (no onClick):", {
label: customLabel || label,
currentView,
itemId: item.id,
isViewMatch,
});
if (isViewMatch) {
return true;
}
}
// Check path-based navigation
if (currentPath && path && path.startsWith("/explorer?")) {
const itemPathParam = new URLSearchParams(path.split("?")[1]).get("path");
if (itemPathParam) {
try {
const itemSdPath = JSON.parse(decodeURIComponent(itemPathParam));
return JSON.stringify(currentPath) === JSON.stringify(itemSdPath);
} catch {
// Fall through to URL-based comparison
}
}
}
if (!path) return false;
// Special routes: exact pathname match
if (!path.startsWith("/explorer?")) {
return location.pathname === path;
}
// Fallback: Explorer routes via URL comparison
if (location.pathname === "/explorer") {
const currentSearchParams = new URLSearchParams(location.search);
const currentPathParam = currentSearchParams.get("path");
const itemPathParam = new URLSearchParams(path.split("?")[1]).get("path");
if (currentPathParam && itemPathParam) {
try {
const currentSdPath = JSON.parse(decodeURIComponent(currentPathParam));
const itemSdPath = JSON.parse(decodeURIComponent(itemPathParam));
return JSON.stringify(currentSdPath) === JSON.stringify(itemSdPath);
} catch {
return currentPathParam === itemPathParam;
}
}
}
return false;
})();
const handleClick = () => {
if (onClick) {
onClick();
// Event handlers
const handleClick = (e: React.MouseEvent) => {
if (effectiveOverrides.onClick) {
effectiveOverrides.onClick(e);
} else if (path) {
// Extract pathname and search from the path
const [pathname, search] = path.includes("?")
? [path.split("?")[0], "?" + path.split("?")[1]]
: [path, ""];
const spaceItemKey = getSpaceItemKeyFromRoute(pathname, search);
loadPreferencesForSpaceItem(spaceItemKey);
navigate(path);
}
};
// Context menu for space items
const contextMenu = useContextMenu({
items: [
{
icon: FolderOpen,
label: "Open",
onClick: () => {
if (path) navigate(path);
},
condition: () => !!path,
},
{
icon: Database,
label: "Index Volume",
onClick: async () => {
if (typeof item.item_type === "object" && "Volume" in item.item_type) {
const volumeItem = item.item_type.Volume;
// Extract volume fingerprint from the item
// We'll need to get this from the volume data
const fingerprint = (item as any).fingerprint || volumeItem.volume_id;
try {
const result = await indexVolume.mutateAsync({
fingerprint: fingerprint.toString(),
scope: "Recursive",
});
console.log("Volume indexed:", result.message);
} catch (err) {
console.error("Failed to index volume:", err);
}
}
},
condition: () => typeof item.item_type === "object" && "Volume" in item.item_type,
},
{ type: "separator" },
{
icon: MagnifyingGlass,
label: "Show in Finder",
onClick: async () => {
// For Path items, get the physical path
if (typeof item.item_type === "object" && "Path" in item.item_type) {
const sdPath = item.item_type.Path.sd_path;
if (typeof sdPath === "object" && "Physical" in sdPath) {
const physicalPath = sdPath.Physical.path;
if (platform.revealFile) {
try {
await platform.revealFile(physicalPath);
} catch (err) {
console.error("Failed to reveal file:", err);
}
}
}
}
},
keybind: "⌘⇧R",
condition: () => {
if (typeof item.item_type === "object" && "Path" in item.item_type) {
const sdPath = item.item_type.Path.sd_path;
return typeof sdPath === "object" && "Physical" in sdPath && !!platform.revealFile;
}
return false;
},
},
{ type: "separator" },
{
icon: Trash,
label: "Remove from Space",
onClick: async () => {
try {
await deleteItem.mutateAsync({ item_id: item.id });
} catch (err) {
console.error("Failed to remove item:", err);
}
},
variant: "danger" as const,
// All space items can be removed (Overview, Recents, Favorites, FileKinds, Locations, Volumes, Tags, Paths)
condition: () => spaceId != null,
},
],
});
const handleContextMenu = async (e: React.MouseEvent) => {
// Use custom handler if provided, otherwise use default
if (onContextMenu) {
onContextMenu(e);
if (effectiveOverrides.onContextMenu) {
effectiveOverrides.onContextMenu(e);
return;
}
@@ -414,196 +260,70 @@ export function SpaceItem({
await contextMenu.show(e);
};
/**
* Drop Target Detection
*
* SpaceItems can be drop targets in two ways:
*
* 1. Insertion Points (all items):
* - Show blue line above/below
* - Allows reordering sidebar items
* - Top/bottom zones (25% or 50% of height)
*
* 2. Move-Into Targets (locations/volumes/folders only):
* - Show blue ring around entire item
* - Allows moving files into that location
* - Middle zone (50% of height, only for drop targets)
*
* Target Types:
* - "location": Indexed location (raw or ItemType::Location)
* - "volume": Storage volume (ItemType::Volume)
* - "folder": Directory path (ItemType::Path with kind=Directory)
*/
const isDropTarget =
isRawLocation ||
(typeof item.item_type === "object" &&
("Location" in item.item_type ||
"Volume" in item.item_type ||
("Path" in item.item_type && resolvedFile?.kind === "Directory")));
let targetType: "location" | "volume" | "folder" | "other" = "other";
if (isRawLocation) {
targetType = "location";
} else if (typeof item.item_type === "object") {
if ("Location" in item.item_type) targetType = "location";
else if ("Volume" in item.item_type) targetType = "volume";
else if ("Path" in item.item_type && resolvedFile?.kind === "Directory") targetType = "folder";
}
// Debug logging for folder drop targets
useEffect(() => {
if (typeof item.item_type === "object" && "Path" in item.item_type) {
console.log("[SpaceItem] Folder item:", {
label,
isDropTarget,
targetType,
hasResolvedFile: !!resolvedFile,
resolvedFileKind: resolvedFile?.kind,
sdPath: item.item_type.Path.sd_path,
});
}
}, [item, isDropTarget, targetType, resolvedFile, label]);
const { setNodeRef: setTopRef, isOver: isOverTop } = useDroppable({
id: `space-item-${item.id}-top`,
disabled: !allowInsertion || isDraggingSortableItem,
data: {
action: "insert-before",
itemId: item.id,
spaceId,
groupId,
},
});
const { setNodeRef: setBottomRef, isOver: isOverBottom } = useDroppable({
id: `space-item-${item.id}-bottom`,
disabled: !allowInsertion || isDraggingSortableItem,
data: {
action: "insert-after",
itemId: item.id,
spaceId,
groupId,
},
});
// Build the target path for drop operations
const targetPath = isRawLocation
? (item as any).sd_path
: targetType === "folder" && typeof item.item_type === "object" && "Path" in item.item_type
? item.item_type.Path.sd_path
: targetType === "volume" && typeof item.item_type === "object" && "Volume" in item.item_type && volumeData
? { Physical: { device_slug: volumeData.device_slug, path: volumeData.mount_path || "/" } }
: targetType === "location" && typeof item.item_type === "object" && "Location" in item.item_type && (item as any).sd_path
? (item as any).sd_path
: undefined;
// Debug log the drop data
useEffect(() => {
if (isDropTarget && targetType === "folder") {
console.log("[SpaceItem] Drop zone data for folder:", {
label,
targetType,
targetPath,
itemId: item.id,
});
}
}, [isDropTarget, targetType, targetPath, label, item.id]);
const { setNodeRef: setMiddleRef, isOver: isOverMiddle } = useDroppable({
id: `space-item-${item.id}-middle`,
disabled: !isDropTarget || isDraggingSortableItem,
data: {
action: "move-into",
targetType,
targetId: item.id,
targetPath,
},
});
// Computed visibility for indicators
const showTopLine =
dropZones.isOverTop &&
!isSortableDragging &&
!dropZones.isDraggingSortableItem;
const showBottomLine =
dropZones.isOverBottom &&
isLastItem &&
!dropZones.isDraggingSortableItem;
const showDropHighlight =
dropZones.isOverMiddle &&
dropZones.isDropTarget &&
!isSortableDragging &&
!dropZones.isDraggingSortableItem;
return (
<div
ref={setSortableRef}
style={style}
className={clsx("relative", isSortableDragging && "opacity-50 z-50")}
className={clsx(
"relative",
isSortableDragging && "opacity-50 z-50",
)}
>
{/* Insertion line indicator - only show top (bottom of previous item handles gaps) */}
{isOverTop && !isSortableDragging && !isDraggingSortableItem && (
<div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
)}
{/* Ring highlight for drop-into */}
{isOverMiddle && isDropTarget && !isSortableDragging && !isDraggingSortableItem && (
<div className="absolute inset-0 rounded-md ring-2 ring-accent/50 ring-inset pointer-events-none z-10" />
)}
<InsertionLine visible={showTopLine} />
<DropHighlight visible={showDropHighlight} />
<div className="relative">
{/* Drop zones - invisible overlays, only active during drag */}
{isDropTarget ? (
<>
{/* Top zone - insertion above */}
<div
ref={setTopRef}
className="absolute left-0 right-0 pointer-events-none"
style={{ top: "-2px", height: "calc(25% + 2px)", zIndex: 10 }}
/>
{/* Middle zone - drop into folder */}
<div
ref={setMiddleRef}
className="absolute left-0 right-0 pointer-events-none"
style={{ top: "25%", height: "50%", zIndex: 11 }}
/>
{/* Bottom zone - insertion below */}
<div
ref={setBottomRef}
className="absolute left-0 right-0 pointer-events-none"
style={{ bottom: "-2px", height: "calc(25% + 2px)", zIndex: 10 }}
/>
</>
) : (
<>
{/* Top zone - insertion above */}
<div
ref={setTopRef}
className="absolute left-0 right-0 pointer-events-none"
style={{ top: "-2px", height: "calc(50% + 2px)", zIndex: 10 }}
/>
{/* Bottom zone - insertion below */}
<div
ref={setBottomRef}
className="absolute left-0 right-0 pointer-events-none"
style={{ bottom: "-2px", height: "calc(50% + 2px)", zIndex: 10 }}
/>
</>
)}
<DropZoneOverlays
isDropTarget={dropZones.isDropTarget}
setTopRef={dropZones.setTopRef}
setBottomRef={dropZones.setBottomRef}
setMiddleRef={dropZones.setMiddleRef}
/>
<button
onClick={handleClick}
onContextMenu={handleContextMenu}
{...(sortable ? { ...sortableAttributes, ...sortableListeners } : {})}
className={clsx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-default",
isActive
? "bg-sidebar-selected/30 text-sidebar-ink"
: (className || "text-sidebar-inkDull"),
isOverMiddle && isDropTarget && !isDraggingSortableItem && "bg-accent/10",
)}
{...(sortable
? { ...sortableAttributes, ...sortableListeners }
: {})}
className={clsx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-default",
isActive
? "bg-sidebar-selected/30 text-sidebar-ink"
: className || "text-sidebar-inkDull",
showDropHighlight && "bg-accent/10",
)}
>
{resolvedFile ? (
<Thumb file={resolvedFile} size={16} className="shrink-0" />
) : iconData.type === "image" ? (
<img src={iconData.icon} alt="" className="size-4" />
<Thumb
file={resolvedFile}
size={16}
className="shrink-0"
/>
) : (
<iconData.icon className="size-4" weight={iconWeight} />
<ItemIcon icon={icon} />
)}
<span className="flex-1 truncate text-left">{label}</span>
{rightComponent}
</button>
</div>
{/* Insertion line indicator - bottom (only for last item to allow dropping at end) */}
{isOverBottom && isLastItem && !isDraggingSortableItem && (
<div className="absolute -bottom-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
)}
<BottomInsertionLine visible={showBottomLine} />
</div>
);
}

View File

@@ -5,6 +5,7 @@ import clsx from 'clsx';
import { useNormalizedQuery, useLibraryMutation } from '../../context';
import type { Tag } from '@sd/ts-client';
import { GroupHeader } from './GroupHeader';
import { useExplorer } from '../Explorer/context';
interface TagsGroupProps {
isCollapsed: boolean;
@@ -20,6 +21,7 @@ interface TagItemProps {
function TagItem({ tag, depth = 0 }: TagItemProps) {
const navigate = useNavigate();
const { loadPreferencesForSpaceItem } = useExplorer();
const [isExpanded, setIsExpanded] = useState(false);
// TODO: Fetch children when hierarchy is implemented
@@ -27,6 +29,7 @@ function TagItem({ tag, depth = 0 }: TagItemProps) {
const hasChildren = children.length > 0;
const handleClick = () => {
loadPreferencesForSpaceItem(`tag:${tag.id}`);
navigate(`/tag/${tag.id}`);
};
@@ -88,6 +91,7 @@ export function TagsGroup({
sortableListeners,
}: TagsGroupProps) {
const navigate = useNavigate();
const { loadPreferencesForSpaceItem } = useExplorer();
const [isCreating, setIsCreating] = useState(false);
const [newTagName, setNewTagName] = useState('');
@@ -115,6 +119,7 @@ export function TagsGroup({
// Navigate to the new tag
if (result?.tag?.id) {
loadPreferencesForSpaceItem(`tag:${result.tag.id}`);
navigate(`/tag/${result.tag.id}`);
}

View File

@@ -0,0 +1,29 @@
// Space item utilities
export {
isOverviewItem,
isRecentsItem,
isFavoritesItem,
isFileKindsItem,
isLocationItem,
isVolumeItem,
isTagItem,
isPathItem,
isRawLocation,
isDropTargetItem,
getDropTargetType,
buildDropTargetPath,
resolveItemMetadata,
type IconData,
type ItemMetadata,
type ResolveMetadataOptions,
type DropTargetType,
} from "./spaceItemUtils";
// Space item hooks
export { useSpaceItemActive } from "./useSpaceItemActive";
export { useSpaceItemDropZones, type UseSpaceItemDropZonesResult } from "./useSpaceItemDropZones";
export { useSpaceItemContextMenu } from "./useSpaceItemContextMenu";
// Space data hooks
export { useSpaces, useSpaceLayout } from "./useSpaces";

View File

@@ -0,0 +1,277 @@
import {
House,
Clock,
Heart,
Folder,
HardDrive,
Tag as TagIcon,
Folders,
} from "@phosphor-icons/react";
import { Location } from "@sd/assets/icons";
import type {
SpaceItem as SpaceItemType,
ItemType,
File,
SdPath,
} from "@sd/ts-client";
import type { Icon } from "@phosphor-icons/react";
// Icon data returned from metadata resolution
export type IconData =
| { type: "component"; icon: Icon }
| { type: "image"; icon: string };
// Metadata resolved for a space item
export interface ItemMetadata {
icon: IconData;
label: string;
path: string | null;
}
// Type guards for ItemType discrimination
export function isOverviewItem(t: ItemType): t is "Overview" {
return t === "Overview";
}
export function isRecentsItem(t: ItemType): t is "Recents" {
return t === "Recents";
}
export function isFavoritesItem(t: ItemType): t is "Favorites" {
return t === "Favorites";
}
export function isFileKindsItem(t: ItemType): t is "FileKinds" {
return t === "FileKinds";
}
export function isLocationItem(
t: ItemType,
): t is { Location: { location_id: string } } {
return typeof t === "object" && "Location" in t;
}
export function isVolumeItem(
t: ItemType,
): t is { Volume: { volume_id: string } } {
return typeof t === "object" && "Volume" in t;
}
export function isTagItem(t: ItemType): t is { Tag: { tag_id: string } } {
return typeof t === "object" && "Tag" in t;
}
export function isPathItem(t: ItemType): t is { Path: { sd_path: SdPath } } {
return typeof t === "object" && "Path" in t;
}
// Check if item is a "raw" location (legacy format with name/sd_path but no item_type)
export function isRawLocation(
item: SpaceItemType | Record<string, unknown>,
): boolean {
return "name" in item && "sd_path" in item && !("item_type" in item);
}
// Get icon data for an item type
function getItemIcon(itemType: ItemType): IconData {
if (isOverviewItem(itemType)) return { type: "component", icon: House };
if (isRecentsItem(itemType)) return { type: "component", icon: Clock };
if (isFavoritesItem(itemType)) return { type: "component", icon: Heart };
if (isFileKindsItem(itemType)) return { type: "component", icon: Folders };
if (isLocationItem(itemType)) return { type: "image", icon: Location };
if (isVolumeItem(itemType)) return { type: "component", icon: HardDrive };
if (isTagItem(itemType)) return { type: "component", icon: TagIcon };
if (isPathItem(itemType)) return { type: "image", icon: Location };
return { type: "image", icon: Location };
}
// Get label for an item type
function getItemLabel(itemType: ItemType, resolvedFile?: File | null): string {
if (isOverviewItem(itemType)) return "Overview";
if (isRecentsItem(itemType)) return "Recents";
if (isFavoritesItem(itemType)) return "Favorites";
if (isFileKindsItem(itemType)) return "File Kinds";
if (isLocationItem(itemType)) return itemType.Location.name || "Unnamed Location";
if (isVolumeItem(itemType)) return itemType.Volume.name || "Unnamed Volume";
if (isTagItem(itemType)) return itemType.Tag.name || "Unnamed Tag";
if (isPathItem(itemType)) {
// Use resolved file name if available, otherwise extract from path
if (resolvedFile?.name) return resolvedFile.name;
const sdPath = itemType.Path.sd_path;
if (typeof sdPath === "object" && "Physical" in sdPath) {
const parts = (
sdPath as { Physical: { path: string } }
).Physical.path.split("/");
return parts[parts.length - 1] || "Path";
}
return "Path";
}
return "Unknown";
}
// Build navigation path for an item
function getItemPath(
itemType: ItemType,
volumeData?: { device_slug: string; mount_path: string },
itemSdPath?: SdPath,
): string | null {
if (isOverviewItem(itemType)) return "/";
if (isRecentsItem(itemType)) return "/recents";
if (isFavoritesItem(itemType)) return "/favorites";
if (isFileKindsItem(itemType)) return "/file-kinds";
if (isLocationItem(itemType)) {
// Use explorer route with location's SD path (passed from item.sd_path)
if (itemSdPath) {
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemSdPath))}`;
}
return null;
}
if (isVolumeItem(itemType)) {
// Navigate to explorer with volume's root path
if (volumeData) {
const sdPath = {
Physical: {
device_slug: volumeData.device_slug,
path: volumeData.mount_path || "/",
},
};
return `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}`;
}
return null;
}
if (isTagItem(itemType)) {
return `/tag/${itemType.Tag.tag_id}`;
}
if (isPathItem(itemType)) {
// Navigate to explorer with the SD path
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`;
}
return null;
}
// Options for resolving item metadata
export interface ResolveMetadataOptions {
volumeData?: { device_slug: string; mount_path: string };
customIcon?: string;
customLabel?: string;
}
// Resolve all metadata for a space item in one call
export function resolveItemMetadata(
item: SpaceItemType | Record<string, unknown>,
options: ResolveMetadataOptions = {},
): ItemMetadata {
const { volumeData, customIcon, customLabel } = options;
// Handle raw location object (legacy format)
if (isRawLocation(item)) {
const rawItem = item as { name?: string; sd_path?: SdPath };
const label = customLabel || rawItem.name || "Unnamed Location";
const path = rawItem.sd_path
? `/explorer?path=${encodeURIComponent(JSON.stringify(rawItem.sd_path))}`
: null;
return {
icon: customIcon
? { type: "image", icon: customIcon }
: { type: "image", icon: Location },
label,
path,
};
}
// Handle proper SpaceItem
const spaceItem = item as SpaceItemType;
const resolvedFile = spaceItem.resolved_file;
const itemSdPath = (spaceItem as SpaceItemType & { sd_path?: SdPath })
.sd_path;
const icon: IconData = customIcon
? { type: "image", icon: customIcon }
: getItemIcon(spaceItem.item_type);
const label =
customLabel ||
resolvedFile?.name ||
getItemLabel(spaceItem.item_type, resolvedFile);
const path = getItemPath(spaceItem.item_type, volumeData, itemSdPath);
return { icon, label, path };
}
// Determine if an item can be a drop target (for files to be moved into)
export function isDropTargetItem(
item: SpaceItemType | Record<string, unknown>,
): boolean {
if (isRawLocation(item)) return true;
const spaceItem = item as SpaceItemType;
const itemType = spaceItem.item_type;
const resolvedFile = spaceItem.resolved_file;
return (
isLocationItem(itemType) ||
isVolumeItem(itemType) ||
(isPathItem(itemType) && resolvedFile?.kind === "Directory")
);
}
// Get the target type for drop operations
export type DropTargetType = "location" | "volume" | "folder" | "other";
export function getDropTargetType(
item: SpaceItemType | Record<string, unknown>,
): DropTargetType {
if (isRawLocation(item)) return "location";
const spaceItem = item as SpaceItemType;
const itemType = spaceItem.item_type;
const resolvedFile = spaceItem.resolved_file;
if (isLocationItem(itemType)) return "location";
if (isVolumeItem(itemType)) return "volume";
if (isPathItem(itemType) && resolvedFile?.kind === "Directory")
return "folder";
return "other";
}
// Build target path for drop operations
export function buildDropTargetPath(
item: SpaceItemType | Record<string, unknown>,
volumeData?: { device_slug: string; mount_path: string },
): SdPath | undefined {
if (isRawLocation(item)) {
return (item as { sd_path?: SdPath }).sd_path;
}
const spaceItem = item as SpaceItemType;
const itemType = spaceItem.item_type;
const itemSdPath = (spaceItem as SpaceItemType & { sd_path?: SdPath })
.sd_path;
if (isPathItem(itemType)) {
return itemType.Path.sd_path;
}
if (isVolumeItem(itemType) && volumeData) {
return {
Physical: {
device_slug: volumeData.device_slug,
path: volumeData.mount_path || "/",
},
};
}
if (isLocationItem(itemType) && itemSdPath) {
return itemSdPath;
}
return undefined;
}

View File

@@ -0,0 +1,109 @@
import { useLocation } from "react-router-dom";
import type { SpaceItem as SpaceItemType } from "@sd/ts-client";
import { useExplorer } from "../../Explorer/context";
interface UseSpaceItemActiveOptions {
item: SpaceItemType;
path: string | null;
hasCustomOnClick: boolean;
}
/**
* Determines if a space item is currently "active" (selected/highlighted).
*
* Active state is determined by matching the current route/view to the item:
* - Virtual views (devices) match by view type and ID
* - Explorer routes match by comparing SD paths
* - Special routes (/, /recents, etc.) match by exact pathname
*/
export function useSpaceItemActive({
item,
path,
hasCustomOnClick,
}: UseSpaceItemActiveOptions): boolean {
const location = useLocation();
const { currentView, currentPath } = useExplorer();
// Items with custom onClick represent virtual views (like device views).
// They should ONLY match via virtual view state, never path-based matching.
if (hasCustomOnClick) {
if (!currentView) return false;
const itemIdStr = String(item.id);
return currentView.view === "device" && currentView.id === itemIdStr;
}
// Check virtual view state for items without custom onClick
if (currentView) {
const itemIdStr = String(item.id);
const isViewMatch =
currentView.view === "device" && currentView.id === itemIdStr;
if (isViewMatch) return true;
// When a virtual view is active, regular items should NOT be active
// even if their path happens to match. Virtual views own the display.
return false;
}
// Check path-based navigation via explorer context
// Only use currentPath matching when we're actually on the explorer route
if (
location.pathname === "/explorer" &&
currentPath &&
path &&
path.startsWith("/explorer?")
) {
const itemPathParam = new URLSearchParams(path.split("?")[1]).get(
"path",
);
if (itemPathParam) {
try {
const itemSdPath = JSON.parse(
decodeURIComponent(itemPathParam),
);
if (
JSON.stringify(currentPath) === JSON.stringify(itemSdPath)
) {
return true;
}
} catch {
// Fall through to URL-based comparison
}
}
}
if (!path) return false;
// Special routes (/, /recents, /favorites, etc.): exact pathname match
if (!path.startsWith("/explorer?")) {
return location.pathname === path;
}
// Explorer routes: compare SD paths via URL
if (location.pathname === "/explorer") {
const currentSearchParams = new URLSearchParams(location.search);
const currentPathParam = currentSearchParams.get("path");
const itemPathParam = new URLSearchParams(path.split("?")[1]).get(
"path",
);
if (currentPathParam && itemPathParam) {
try {
const currentSdPath = JSON.parse(
decodeURIComponent(currentPathParam),
);
const itemSdPath = JSON.parse(
decodeURIComponent(itemPathParam),
);
return (
JSON.stringify(currentSdPath) === JSON.stringify(itemSdPath)
);
} catch {
return currentPathParam === itemPathParam;
}
}
}
return false;
}

View File

@@ -0,0 +1,133 @@
import { useNavigate } from "react-router-dom";
import {
FolderOpen,
MagnifyingGlass,
Trash,
Database,
} from "@phosphor-icons/react";
import type { SpaceItem as SpaceItemType } from "@sd/ts-client";
import {
useContextMenu,
type ContextMenuItem,
type ContextMenuResult,
} from "../../../hooks/useContextMenu";
import { usePlatform } from "../../../platform";
import { useLibraryMutation } from "../../../context";
import { isVolumeItem, isPathItem } from "./spaceItemUtils";
import { useExplorer, getSpaceItemKeyFromRoute } from "../../Explorer/context";
interface UseSpaceItemContextMenuOptions {
item: SpaceItemType;
path: string | null;
spaceId?: string;
}
/**
* Provides context menu functionality for space items.
*
* Menu items include:
* - Open: Navigate to the item's path
* - Index Volume: Trigger indexing for volume items
* - Show in Finder: Reveal file in OS file manager (Path items only)
* - Remove from Space: Delete the item from the current space
*/
export function useSpaceItemContextMenu({
item,
path,
spaceId,
}: UseSpaceItemContextMenuOptions): ContextMenuResult {
const navigate = useNavigate();
const platform = usePlatform();
const { loadPreferencesForSpaceItem } = useExplorer();
const deleteItem = useLibraryMutation("spaces.delete_item");
const indexVolume = useLibraryMutation("volumes.index");
const items: ContextMenuItem[] = [
{
icon: FolderOpen,
label: "Open",
onClick: () => {
if (path) {
// Extract pathname and search from the path
const [pathname, search] = path.includes("?")
? [path.split("?")[0], "?" + path.split("?")[1]]
: [path, ""];
const spaceItemKey = getSpaceItemKeyFromRoute(pathname, search);
loadPreferencesForSpaceItem(spaceItemKey);
navigate(path);
}
},
condition: () => !!path,
},
{
icon: Database,
label: "Index Volume",
onClick: async () => {
if (isVolumeItem(item.item_type)) {
const fingerprint =
(item as SpaceItemType & { fingerprint?: string })
.fingerprint || item.item_type.Volume.volume_id;
try {
const result = await indexVolume.mutateAsync({
fingerprint: fingerprint.toString(),
scope: "Recursive",
});
console.log("Volume indexed:", result.message);
} catch (err) {
console.error("Failed to index volume:", err);
}
}
},
condition: () => isVolumeItem(item.item_type),
},
{ type: "separator" },
{
icon: MagnifyingGlass,
label: "Show in Finder",
onClick: async () => {
if (isPathItem(item.item_type)) {
const sdPath = item.item_type.Path.sd_path;
if (typeof sdPath === "object" && "Physical" in sdPath) {
const physicalPath = (
sdPath as { Physical: { path: string } }
).Physical.path;
if (platform.revealFile) {
try {
await platform.revealFile(physicalPath);
} catch (err) {
console.error("Failed to reveal file:", err);
}
}
}
}
},
keybind: "⌘⇧R",
condition: () => {
if (!isPathItem(item.item_type)) return false;
const sdPath = item.item_type.Path.sd_path;
return (
typeof sdPath === "object" &&
"Physical" in sdPath &&
!!platform.revealFile
);
},
},
{ type: "separator" },
{
icon: Trash,
label: "Remove from Space",
onClick: async () => {
try {
await deleteItem.mutateAsync({ item_id: item.id });
} catch (err) {
console.error("Failed to remove item:", err);
}
},
variant: "danger" as const,
condition: () => spaceId != null,
},
];
return useContextMenu({ items });
}

View File

@@ -0,0 +1,108 @@
import { useDroppable, useDndContext } from "@dnd-kit/core";
import type { SpaceItem as SpaceItemType, SdPath } from "@sd/ts-client";
import {
isDropTargetItem,
getDropTargetType,
buildDropTargetPath,
type DropTargetType,
} from "./spaceItemUtils";
interface UseSpaceItemDropZonesOptions {
item: SpaceItemType;
allowInsertion: boolean;
spaceId?: string;
groupId?: string | null;
volumeData?: { device_slug: string; mount_path: string };
}
interface DropZoneRefs {
setTopRef: (node: HTMLElement | null) => void;
setBottomRef: (node: HTMLElement | null) => void;
setMiddleRef: (node: HTMLElement | null) => void;
}
interface DropZoneState {
isOverTop: boolean;
isOverBottom: boolean;
isOverMiddle: boolean;
isDropTarget: boolean;
targetType: DropTargetType;
targetPath: SdPath | undefined;
isDraggingSortableItem: boolean;
}
export type UseSpaceItemDropZonesResult = DropZoneRefs & DropZoneState;
/**
* Manages drop zones for a space item.
*
* SpaceItems support two types of drop interactions:
* 1. Insertion (reordering): Blue line above/below for sidebar item reordering
* 2. Move-into (file operations): Blue ring for moving files into location/folder
*/
export function useSpaceItemDropZones({
item,
allowInsertion,
spaceId,
groupId,
volumeData,
}: UseSpaceItemDropZonesOptions): UseSpaceItemDropZonesResult {
const { active } = useDndContext();
// Disable insertion zones when dragging groups or space items (they have 'label' in data)
const isDraggingSortableItem = active?.data?.current?.label != null;
// Determine if this item can receive file drops
const isDropTarget = isDropTargetItem(item);
const targetType = getDropTargetType(item);
const targetPath = buildDropTargetPath(item, volumeData);
// Top zone: insertion above
const { setNodeRef: setTopRef, isOver: isOverTop } = useDroppable({
id: `space-item-${item.id}-top`,
disabled: !allowInsertion || isDraggingSortableItem,
data: {
action: "insert-before",
itemId: item.id,
spaceId,
groupId,
},
});
// Bottom zone: insertion below
const { setNodeRef: setBottomRef, isOver: isOverBottom } = useDroppable({
id: `space-item-${item.id}-bottom`,
disabled: !allowInsertion || isDraggingSortableItem,
data: {
action: "insert-after",
itemId: item.id,
spaceId,
groupId,
},
});
// Middle zone: drop into folder/location
const { setNodeRef: setMiddleRef, isOver: isOverMiddle } = useDroppable({
id: `space-item-${item.id}-middle`,
disabled: !isDropTarget || isDraggingSortableItem,
data: {
action: "move-into",
targetType,
targetId: item.id,
targetPath,
},
});
return {
setTopRef,
setBottomRef,
setMiddleRef,
isOverTop,
isOverBottom,
isOverMiddle,
isDropTarget,
targetType,
targetPath,
isDraggingSortableItem,
};
}

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from "react";
import { GearSix, Palette } from "@phosphor-icons/react";
import { GearSix, Palette, ArrowsClockwise, ListBullets, CircleNotch, ArrowsOut, FunnelSimple } from "@phosphor-icons/react";
import { useSidebarStore, useLibraryMutation } from "@sd/ts-client";
import type { SpaceGroup as SpaceGroupType, SpaceItem as SpaceItemType } from "@sd/ts-client";
import { TopBarButton, Popover, usePopover } from "@sd/ui";
import { useSpaces, useSpaceLayout } from "./hooks/useSpaces";
import { SpaceSwitcher } from "./SpaceSwitcher";
import { SpaceGroup } from "./SpaceGroup";
@@ -11,12 +12,19 @@ import { SpaceCustomizationPanel } from "./SpaceCustomizationPanel";
import { useSpacedriveClient } from "../../context";
import { useLibraries } from "../../hooks/useLibraries";
import { usePlatform } from "../../platform";
import { JobManagerPopover } from "../JobManager/JobManagerPopover";
import { SyncMonitorPopover } from "../SyncMonitor";
import { useJobs } from "../JobManager/hooks/useJobs";
import { useSyncCount } from "../SyncMonitor/hooks/useSyncCount";
import { useSyncMonitor } from "../SyncMonitor/hooks/useSyncMonitor";
import { PeerList } from "../SyncMonitor/components/PeerList";
import { ActivityFeed } from "../SyncMonitor/components/ActivityFeed";
import { JobList } from "../JobManager/components/JobList";
import { motion } from "framer-motion";
import { CARD_HEIGHT } from "../JobManager/types";
import clsx from "clsx";
import { useDroppable, useDndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useNavigate } from "react-router-dom";
// Wrapper that adds a space-level drop zone before each group and makes it sortable
function SpaceGroupWithDropZone({
@@ -86,6 +94,205 @@ function SpaceGroupWithDropZone({
);
}
// Sync Monitor Button with Popover
function SyncButton() {
const popover = usePopover();
const navigate = useNavigate();
const [showActivityFeed, setShowActivityFeed] = useState(false);
const { onlinePeerCount, isSyncing } = useSyncCount();
const sync = useSyncMonitor();
useEffect(() => {
if (popover.open) {
setShowActivityFeed(false);
}
}, [popover.open]);
const getStateColor = (state: string) => {
switch (state) {
case "Ready":
return "bg-green-500";
case "Backfilling":
return "bg-yellow-500";
case "CatchingUp":
return "bg-accent";
case "Uninitialized":
return "bg-ink-faint";
case "Paused":
return "bg-ink-dull";
default:
return "bg-ink-faint";
}
};
return (
<Popover
popover={popover}
trigger={
<TopBarButton
icon={({ className, ...props }) =>
isSyncing ? (
<CircleNotch className={clsx(className, "animate-spin")} {...props} />
) : (
<ArrowsClockwise className={className} {...props} />
)
}
title="Sync Monitor"
/>
}
side="top"
align="start"
sideOffset={8}
className="w-[380px] max-h-[520px] z-50 !p-0 !bg-app !rounded-xl"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-app-line">
<h3 className="text-sm font-semibold text-ink">Sync Monitor</h3>
<div className="flex items-center gap-2">
{onlinePeerCount > 0 && (
<span className="text-xs text-ink-dull">
{onlinePeerCount} {onlinePeerCount === 1 ? "peer" : "peers"} online
</span>
)}
<TopBarButton
icon={ArrowsOut}
onClick={() => navigate("/sync")}
title="Open full sync monitor"
/>
<TopBarButton
icon={FunnelSimple}
active={showActivityFeed}
onClick={() => setShowActivityFeed(!showActivityFeed)}
title={showActivityFeed ? "Show peers" : "Show activity feed"}
/>
</div>
</div>
{popover.open && (
<>
<div className="px-4 py-2 border-b border-app-line bg-app-box/50">
<div className="flex items-center gap-2">
<div className={`size-2 rounded-full ${getStateColor(sync.currentState)}`} />
<span className="text-xs font-medium text-ink-dull">{sync.currentState}</span>
</div>
</div>
<motion.div
className="overflow-y-auto no-scrollbar"
initial={false}
animate={{
height: showActivityFeed
? Math.min(sync.recentActivity.length * 40 + 16, 400)
: Math.min(sync.peers.length * 80 + 16, 400),
}}
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
>
{showActivityFeed ? (
<ActivityFeed activities={sync.recentActivity} />
) : (
<PeerList peers={sync.peers} currentState={sync.currentState} />
)}
</motion.div>
</>
)}
</Popover>
);
}
// Jobs Button with Popover
function JobsButton({
activeJobCount,
hasRunningJobs,
jobs,
pause,
resume,
cancel,
navigate
}: {
activeJobCount: number;
hasRunningJobs: boolean;
jobs: any[];
pause: (jobId: string) => Promise<void>;
resume: (jobId: string) => Promise<void>;
cancel: (jobId: string) => Promise<void>;
navigate: any;
}) {
const popover = usePopover();
const [showOnlyRunning, setShowOnlyRunning] = useState(true);
useEffect(() => {
if (popover.open) {
setShowOnlyRunning(true);
}
}, [popover.open]);
const filteredJobs = showOnlyRunning
? jobs.filter((job) => job.status === "running" || job.status === "paused")
: jobs;
return (
<Popover
popover={popover}
trigger={
<TopBarButton
icon={({ className, ...props }) =>
hasRunningJobs ? (
<CircleNotch className={clsx(className, "animate-spin")} {...props} />
) : (
<ListBullets className={className} {...props} />
)
}
title="Job Manager"
/>
}
side="top"
align="start"
sideOffset={8}
className="w-[360px] max-h-[480px] z-50 !p-0 !bg-app !rounded-xl"
>
<div className="flex items-center justify-between px-4 py-3 border-b border-app-line">
<h3 className="text-sm font-semibold text-ink">Job Manager</h3>
<div className="flex items-center gap-2">
{activeJobCount > 0 && (
<span className="text-xs text-ink-dull">{activeJobCount} active</span>
)}
<TopBarButton
icon={ArrowsOut}
onClick={() => navigate("/jobs")}
title="Open full jobs screen"
/>
<TopBarButton
icon={FunnelSimple}
active={showOnlyRunning}
onClick={() => setShowOnlyRunning(!showOnlyRunning)}
title={showOnlyRunning ? "Show all jobs" : "Show only active jobs"}
/>
</div>
</div>
{popover.open && (
<motion.div
className="overflow-y-auto no-scrollbar"
initial={false}
animate={{
height:
filteredJobs.length === 0
? CARD_HEIGHT + 16
: Math.min(filteredJobs.length * (CARD_HEIGHT + 8) + 16, 400),
}}
transition={{ duration: 0.2, ease: [0.25, 1, 0.5, 1] }}
>
<JobList jobs={filteredJobs} onPause={pause} onResume={resume} onCancel={cancel} />
</motion.div>
)}
</Popover>
);
}
interface SpacesSidebarProps {
isPreviewActive?: boolean;
}
@@ -93,12 +300,17 @@ interface SpacesSidebarProps {
export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
const client = useSpacedriveClient();
const platform = usePlatform();
const navigate = useNavigate();
const { data: libraries } = useLibraries();
const [currentLibraryId, setCurrentLibraryId] = useState<string | null>(
() => client.getCurrentLibraryId(),
);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
// Get sync and job status for icons
const { onlinePeerCount, isSyncing } = useSyncCount();
const { activeJobCount, hasRunningJobs, jobs, pause, resume, cancel } = useJobs();
const { currentSpaceId, setCurrentSpace } = useSidebarStore();
const { data: spacesData } = useSpaces();
const spaces = spacesData?.spaces;
@@ -209,20 +421,27 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
</div>
{/* Sync Monitor, Job Manager, Customize & Settings (pinned to bottom) */}
<div className="space-y-0.5">
<SyncMonitorPopover />
<JobManagerPopover />
<button
onClick={() => setCustomizePanelOpen(true)}
className={clsx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium",
"text-sidebar-inkDull cursor-default",
)}
>
<Palette className="size-4" weight="bold" />
<span className="truncate">Customize</span>
</button>
<button
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SyncButton />
<JobsButton
activeJobCount={activeJobCount}
hasRunningJobs={hasRunningJobs}
jobs={jobs}
pause={pause}
resume={resume}
cancel={cancel}
navigate={navigate}
/>
<TopBarButton
icon={Palette}
title="Customize"
onClick={() => setCustomizePanelOpen(true)}
/>
</div>
<TopBarButton
icon={GearSix}
title="Settings"
onClick={() => {
if (platform.showWindow) {
platform.showWindow({ type: "Settings", page: "general" }).catch(err =>
@@ -230,14 +449,7 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
);
}
}}
className={clsx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium",
"text-sidebar-inkDull cursor-default",
)}
>
<GearSix className="size-4" weight="bold" />
<span className="truncate">Settings</span>
</button>
/>
</div>
</nav>
</div>

View File

@@ -18,7 +18,7 @@ export interface ContextMenuConfig {
items: ContextMenuItem[];
}
interface ContextMenuResult {
export interface ContextMenuResult {
show: (e: React.MouseEvent) => Promise<void>;
menuData: ContextMenuItem[] | null;
closeMenu: () => void;

View File

@@ -13,6 +13,7 @@ export { LocationCacheDemo } from "./LocationCacheDemo";
export { Inspector, PopoutInspector } from "./Inspector";
export type { InspectorVariant } from "./Inspector";
export { QuickPreview } from "./components/QuickPreview";
export { JobsScreen } from "./components/JobManager";
export { Settings } from "./Settings";
export { Spacedrop } from "./Spacedrop";
export { PairingModal } from "./components/PairingModal";

View File

@@ -1,6 +1,12 @@
import { useState } from "react";
import { useState, useRef, useEffect } from "react";
import { motion } from "framer-motion";
import { HardDrive, Plus, Database } from "@phosphor-icons/react";
import {
HardDrive,
Plus,
Database,
CaretLeft,
CaretRight,
} from "@phosphor-icons/react";
import Masonry from "react-masonry-css";
import DriveIcon from "@sd/assets/icons/Drive.png";
import HDDIcon from "@sd/assets/icons/HDD.png";
@@ -10,6 +16,7 @@ import DriveAmazonS3Icon from "@sd/assets/icons/Drive-AmazonS3.png";
import DriveGoogleDriveIcon from "@sd/assets/icons/Drive-GoogleDrive.png";
import DriveDropboxIcon from "@sd/assets/icons/Drive-Dropbox.png";
import LocationIcon from "@sd/assets/icons/Location.png";
import { TopBarButton } from "@sd/ui/TopBarButton";
import {
useNormalizedQuery,
useLibraryMutation,
@@ -386,54 +393,11 @@ function DeviceCard({
{/* Locations for this device */}
{locations.length > 0 && (
<div className="px-3 py-3 border-b border-app-line">
<div className="flex flex-wrap gap-2">
{locations.map((location) => {
const isSelected =
selectedLocationId === location.id;
return (
<button
key={location.id}
onClick={() => {
if (isSelected) {
onLocationSelect?.(null);
} else {
onLocationSelect?.(location);
}
}}
className="flex flex-col items-center gap-2 p-1 rounded-lg transition-all min-w-[80px]"
>
<div
className={clsx(
"rounded-lg p-2",
isSelected
? "bg-app-box"
: "bg-transparent",
)}
>
<img
src={LocationIcon}
alt={location.name}
className="size-12 opacity-80"
/>
</div>
<div className="w-full flex flex-col items-center">
<div
className={clsx(
"text-xs truncate px-2 py-0.5 rounded-md inline-block max-w-full",
isSelected
? "bg-accent text-white"
: "text-ink",
)}
>
{location.name}
</div>
</div>
</button>
);
})}
</div>
</div>
<LocationsScroller
locations={locations}
selectedLocationId={selectedLocationId}
onLocationSelect={onLocationSelect}
/>
)}
{/* Volumes for this device */}
@@ -460,6 +424,128 @@ function DeviceCard({
);
}
interface LocationsScrollerProps {
locations: Location[];
selectedLocationId: string | null;
onLocationSelect?: (location: Location | null) => void;
}
function LocationsScroller({
locations,
selectedLocationId,
onLocationSelect,
}: LocationsScrollerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 1);
};
useEffect(() => {
updateScrollState();
window.addEventListener("resize", updateScrollState);
return () => window.removeEventListener("resize", updateScrollState);
}, [locations]);
const scroll = (direction: "left" | "right") => {
if (!scrollRef.current) return;
const scrollAmount = 200;
scrollRef.current.scrollBy({
left: direction === "left" ? -scrollAmount : scrollAmount,
behavior: "smooth",
});
};
return (
<div className="px-3 py-3 border-b border-app-line">
<div className="relative">
{/* Left fade and button */}
{canScrollLeft && (
<>
<div className="absolute left-0 top-0 bottom-0 w-12 bg-gradient-to-r from-app-darkBox to-transparent z-10 pointer-events-none" />
<div className="absolute left-1 top-1/2 -translate-y-1/2 z-20">
<TopBarButton
icon={CaretLeft}
onClick={() => scroll("left")}
/>
</div>
</>
)}
{/* Scrollable container */}
<div
ref={scrollRef}
onScroll={updateScrollState}
className="flex gap-2 overflow-x-auto scrollbar-hide"
style={{ scrollbarWidth: "none" }}
>
{locations.map((location) => {
const isSelected = selectedLocationId === location.id;
return (
<button
key={location.id}
onClick={() => {
if (isSelected) {
onLocationSelect?.(null);
} else {
onLocationSelect?.(location);
}
}}
className="flex flex-col items-center gap-2 p-1 rounded-lg transition-all min-w-[80px] flex-shrink-0"
>
<div
className={clsx(
"rounded-lg p-2",
isSelected
? "bg-app-box"
: "bg-transparent",
)}
>
<img
src={LocationIcon}
alt={location.name}
className="size-12 opacity-80"
/>
</div>
<div className="w-full flex flex-col items-center">
<div
className={clsx(
"text-xs truncate px-2 py-0.5 rounded-md inline-block max-w-full",
isSelected
? "bg-accent text-white"
: "text-ink",
)}
>
{location.name}
</div>
</div>
</button>
);
})}
</div>
{/* Right fade and button */}
{canScrollRight && (
<>
<div className="absolute right-0 top-0 bottom-0 w-12 bg-gradient-to-l from-app-darkBox to-transparent z-10 pointer-events-none" />
<div className="absolute right-1 top-1/2 -translate-y-1/2 z-20">
<TopBarButton
icon={CaretRight}
onClick={() => scroll("right")}
/>
</div>
</>
)}
</div>
</div>
);
}
interface VolumeBarProps {
volume: VolumeItem;
index: number;

View File

@@ -419,7 +419,14 @@ export type DeviceInfo = { id: string; name: string; os: string; hardware_model:
*/
export type DeviceMetricsSnapshot = { device_id: string; device_name: string; entries_received: number; last_seen: string; is_online: boolean };
export type DeviceRevokeInput = { device_id: string };
export type DeviceRevokeInput = { device_id: string;
/**
* Whether to also remove the device from all library databases
*
* If false (default), only unpairs from network but keeps device history in libraries.
* If true, completely removes device from libraries (deletes all records).
*/
remove_from_library?: boolean };
export type DeviceRevokeOutput = { revoked: boolean };
@@ -614,6 +621,28 @@ export type EntryKind =
*/
"Symlink";
/**
* Input for resetting the ephemeral cache
*/
export type EphemeralCacheResetInput = {
/**
* Confirmation flag to prevent accidental cache clearing
*/
confirm: boolean };
/**
* Output from resetting the ephemeral cache
*/
export type EphemeralCacheResetOutput = {
/**
* Number of paths that were cleared from the cache
*/
cleared_paths: number;
/**
* Message describing the result
*/
message: string };
/**
* Status of the unified ephemeral index cache
*/
@@ -714,7 +743,23 @@ export type Event = "CoreStarted" | "CoreShutdown" | { LibraryCreated: { id: str
/**
* How the library was created (manual, sync, cloud import)
*/
source?: LibraryCreationSource } } | { LibraryOpened: { id: string; name: string; path: string } } | { LibraryClosed: { id: string; name: string } } | { LibraryDeleted: { id: string; name: string; deleted_data: boolean } } | { LibraryStatisticsUpdated: { library_id: string; statistics: LibraryStatistics } } |
source?: LibraryCreationSource } } | { LibraryOpened: { id: string; name: string; path: string } } | { LibraryClosed: { id: string; name: string } } | { LibraryDeleted: { id: string; name: string; deleted_data: boolean } } | { LibraryLoadFailed: {
/**
* Library ID if config was readable, None otherwise
*/
id: string | null;
/**
* Path to the library directory
*/
path: string;
/**
* Human-readable error message
*/
error: string;
/**
* Error type for frontend categorization (e.g., "DatabaseError", "ConfigError")
*/
error_type: string } } | { LibraryStatisticsUpdated: { library_id: string; statistics: LibraryStatistics } } |
/**
* Refresh event - signals that all frontend caches should be invalidated
* Emitted after major data recalculations (e.g., volume unique_bytes refresh)
@@ -1623,13 +1668,13 @@ export type JobCancelOutput = { job_id: string; success: boolean };
*/
export type JobId = string;
export type JobInfoOutput = { id: string; name: string; status: JobStatus; progress: number; started_at: string; completed_at: string | null; error_message: string | null };
export type JobInfoOutput = { id: string; name: string; status: JobStatus; progress: number; created_at: string; started_at: string | null; completed_at: string | null; error_message: string | null };
export type JobInfoQueryInput = { job_id: string };
export type JobListInput = { status: JobStatus | null };
export type JobListItem = { id: string; name: string; device_id: string; status: JobStatus; progress: number; action_type: string | null; action_context: ActionContextInfo | null };
export type JobListItem = { id: string; name: string; device_id: string; status: JobStatus; progress: number; action_type: string | null; action_context: ActionContextInfo | null; created_at: string; started_at: string | null; completed_at: string | null };
export type JobListOutput = { jobs: JobListItem[] };
@@ -4052,213 +4097,215 @@ success: boolean };
// ===== API Type Unions =====
export type CoreAction =
{ type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput }
{ type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
| { type: 'models.whisper.delete'; input: DeleteWhisperModelInput; output: DeleteWhisperModelOutput }
| { type: 'models.whisper.download'; input: DownloadWhisperModelInput; output: DownloadWhisperModelOutput }
| { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
| { type: 'core.ephemeral_reset'; input: EphemeralCacheResetInput; output: EphemeralCacheResetOutput }
| { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput }
| { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput }
| { type: 'network.start'; input: NetworkStartInput; output: NetworkStartOutput }
| { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
| { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput }
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
| { type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput }
| { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput }
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
| { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
| { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput }
| { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput }
| { type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput }
| { type: 'core.reset'; input: ResetDataInput; output: ResetDataOutput }
| { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput }
;
export type LibraryAction =
{ type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
| { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
| { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
| { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput }
| { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput }
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
| { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput }
| { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput }
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
| { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput }
| { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput }
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
| { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput }
| { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput }
| { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt }
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
| { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput }
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
| { type: 'spaces.reorder_items'; input: ReorderItemsInput; output: ReorderOutput }
| { type: 'spaces.reorder_groups'; input: ReorderGroupsInput; output: ReorderOutput }
{ type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
| { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput }
| { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput }
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
| { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput }
| { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput }
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
| { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput }
| { type: 'indexing.start'; input: IndexInput; output: JobReceipt }
| { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput }
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
| { type: 'spaces.reorder_items'; input: ReorderItemsInput; output: ReorderOutput }
| { type: 'spaces.reorder_groups'; input: ReorderGroupsInput; output: ReorderOutput }
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
| { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput }
| { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
| { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput }
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
| { type: 'media.splat.generate'; input: GenerateSplatInput; output: GenerateSplatOutput }
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
| { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput }
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
| { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput }
| { type: 'indexing.start'; input: IndexInput; output: JobReceipt }
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
| { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
| { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput }
| { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt }
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
| { type: 'volumes.index'; input: IndexVolumeInput; output: IndexVolumeOutput }
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
;
export type CoreQuery =
{ type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus }
{ type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput }
| { type: 'jobs.remote.all_devices'; input: RemoteJobsAllDevicesInput; output: RemoteJobsAllDevicesOutput }
| { type: 'jobs.remote.for_device'; input: RemoteJobsForDeviceInput; output: RemoteJobsForDeviceOutput }
| { type: 'core.events.list'; input: ListEventsInput; output: ListEventsOutput }
| { type: 'core.ephemeral_status'; input: EphemeralCacheStatusInput; output: EphemeralCacheStatus }
| { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput }
| { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
| { type: 'core.status'; input: Empty; output: CoreStatus }
| { type: 'jobs.remote.all_devices'; input: RemoteJobsAllDevicesInput; output: RemoteJobsAllDevicesOutput }
| { type: 'jobs.remote.for_device'; input: RemoteJobsForDeviceInput; output: RemoteJobsForDeviceOutput }
| { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput }
| { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput }
| { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] }
| { type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus }
| { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput }
| { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
;
export type LibraryQuery =
{ type: 'files.by_id'; input: FileByIdQuery; output: File }
| { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
| { type: 'jobs.list'; input: JobListInput; output: JobListOutput }
{ type: 'test.ping'; input: PingInput; output: PingOutput }
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
| { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput }
| { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout }
| { type: 'files.content_kind_stats'; input: ContentKindStatsInput; output: ContentKindStatsOutput }
| { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput }
| { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput }
| { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput }
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
| { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput }
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
| { type: 'test.ping'; input: PingInput; output: PingOutput }
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
| { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput }
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
| { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput }
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
| { type: 'locations.validate_path'; input: ValidateLocationPathInput; output: ValidateLocationPathOutput }
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
| { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput }
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: Library }
| { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput }
| { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
| { type: 'devices.list'; input: ListLibraryDevicesInput; output: [Device] }
| { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput }
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
| { type: 'jobs.list'; input: JobListInput; output: JobListOutput }
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: Library }
| { type: 'files.by_id'; input: FileByIdQuery; output: File }
| { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput }
| { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput }
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
;
// ===== Wire Method Mappings =====
export const WIRE_METHODS = {
coreActions: {
'libraries.delete': 'action:libraries.delete.input',
'libraries.create': 'action:libraries.create.input',
'models.whisper.delete': 'action:models.whisper.delete.input',
'models.whisper.download': 'action:models.whisper.download.input',
'libraries.create': 'action:libraries.create.input',
'core.ephemeral_reset': 'action:core.ephemeral_reset.input',
'network.pair.cancel': 'action:network.pair.cancel.input',
'network.sync_setup': 'action:network.sync_setup.input',
'network.start': 'action:network.start.input',
'network.pair.generate': 'action:network.pair.generate.input',
'libraries.open': 'action:libraries.open.input',
'network.spacedrop.send': 'action:network.spacedrop.send.input',
'network.device.revoke': 'action:network.device.revoke.input',
'network.stop': 'action:network.stop.input',
'network.pair.join': 'action:network.pair.join.input',
'network.spacedrop.send': 'action:network.spacedrop.send.input',
'network.pair.generate': 'action:network.pair.generate.input',
'network.device.revoke': 'action:network.device.revoke.input',
'network.sync_setup': 'action:network.sync_setup.input',
'network.pair.cancel': 'action:network.pair.cancel.input',
'libraries.delete': 'action:libraries.delete.input',
'core.reset': 'action:core.reset.input',
'libraries.open': 'action:libraries.open.input',
},
libraryActions: {
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
'indexing.verify': 'action:indexing.verify.input',
'locations.export': 'action:locations.export.input',
'jobs.cancel': 'action:jobs.cancel.input',
'files.delete': 'action:files.delete.input',
'spaces.delete_item': 'action:spaces.delete_item.input',
'locations.remove': 'action:locations.remove.input',
'spaces.delete_group': 'action:spaces.delete_group.input',
'volumes.speed_test': 'action:volumes.speed_test.input',
'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input',
'locations.add': 'action:locations.add.input',
'media.speech.transcribe': 'action:media.speech.transcribe.input',
'spaces.update_group': 'action:spaces.update_group.input',
'spaces.update': 'action:spaces.update.input',
'spaces.add_group': 'action:spaces.add_group.input',
'media.splat.generate': 'action:media.splat.generate.input',
'tags.create': 'action:tags.create.input',
'locations.update': 'action:locations.update.input',
'jobs.pause': 'action:jobs.pause.input',
'libraries.export': 'action:libraries.export.input',
'locations.enable_indexing': 'action:locations.enable_indexing.input',
'files.copy': 'action:files.copy.input',
'tags.apply': 'action:tags.apply.input',
'volumes.untrack': 'action:volumes.untrack.input',
'spaces.delete': 'action:spaces.delete.input',
'spaces.add_item': 'action:spaces.add_item.input',
'volumes.index': 'action:volumes.index.input',
'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input',
'media.thumbnail': 'action:media.thumbnail.input',
'locations.rescan': 'action:locations.rescan.input',
'volumes.track': 'action:volumes.track.input',
'spaces.create': 'action:spaces.create.input',
'libraries.rename': 'action:libraries.rename.input',
'volumes.add_cloud': 'action:volumes.add_cloud.input',
'locations.import': 'action:locations.import.input',
'media.proxy.generate': 'action:media.proxy.generate.input',
'spaces.reorder_items': 'action:spaces.reorder_items.input',
'spaces.reorder_groups': 'action:spaces.reorder_groups.input',
'jobs.resume': 'action:jobs.resume.input',
'volumes.refresh': 'action:volumes.refresh.input',
'volumes.track': 'action:volumes.track.input',
'spaces.update': 'action:spaces.update.input',
'volumes.speed_test': 'action:volumes.speed_test.input',
'volumes.untrack': 'action:volumes.untrack.input',
'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input',
'locations.update': 'action:locations.update.input',
'locations.triggerJob': 'action:locations.triggerJob.input',
'indexing.start': 'action:indexing.start.input',
'spaces.delete': 'action:spaces.delete.input',
'spaces.add_item': 'action:spaces.add_item.input',
'libraries.export': 'action:libraries.export.input',
'spaces.reorder_items': 'action:spaces.reorder_items.input',
'spaces.reorder_groups': 'action:spaces.reorder_groups.input',
'volumes.add_cloud': 'action:volumes.add_cloud.input',
'spaces.update_group': 'action:spaces.update_group.input',
'locations.enable_indexing': 'action:locations.enable_indexing.input',
'locations.add': 'action:locations.add.input',
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
'spaces.create': 'action:spaces.create.input',
'locations.remove': 'action:locations.remove.input',
'jobs.pause': 'action:jobs.pause.input',
'media.splat.generate': 'action:media.splat.generate.input',
'files.copy': 'action:files.copy.input',
'media.speech.transcribe': 'action:media.speech.transcribe.input',
'indexing.verify': 'action:indexing.verify.input',
'spaces.add_group': 'action:spaces.add_group.input',
'tags.apply': 'action:tags.apply.input',
'locations.import': 'action:locations.import.input',
'media.ocr.extract': 'action:media.ocr.extract.input',
'indexing.start': 'action:indexing.start.input',
'media.proxy.generate': 'action:media.proxy.generate.input',
'locations.rescan': 'action:locations.rescan.input',
'libraries.rename': 'action:libraries.rename.input',
'locations.export': 'action:locations.export.input',
'spaces.delete_item': 'action:spaces.delete_item.input',
'tags.create': 'action:tags.create.input',
'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input',
'media.thumbnail': 'action:media.thumbnail.input',
'jobs.cancel': 'action:jobs.cancel.input',
'volumes.index': 'action:volumes.index.input',
'files.delete': 'action:files.delete.input',
},
coreQueries: {
'network.status': 'query:network.status',
'network.devices.list': 'query:network.devices.list',
'jobs.remote.all_devices': 'query:jobs.remote.all_devices',
'jobs.remote.for_device': 'query:jobs.remote.for_device',
'core.events.list': 'query:core.events.list',
'core.ephemeral_status': 'query:core.ephemeral_status',
'network.sync_setup.discover': 'query:network.sync_setup.discover',
'network.pair.status': 'query:network.pair.status',
'core.status': 'query:core.status',
'jobs.remote.all_devices': 'query:jobs.remote.all_devices',
'jobs.remote.for_device': 'query:jobs.remote.for_device',
'models.whisper.list': 'query:models.whisper.list',
'network.devices.list': 'query:network.devices.list',
'libraries.list': 'query:libraries.list',
'network.status': 'query:network.status',
'models.whisper.list': 'query:models.whisper.list',
'network.pair.status': 'query:network.pair.status',
},
libraryQueries: {
'files.by_id': 'query:files.by_id',
'volumes.list': 'query:volumes.list',
'jobs.info': 'query:jobs.info',
'jobs.list': 'query:jobs.list',
'test.ping': 'query:test.ping',
'files.by_path': 'query:files.by_path',
'files.unique_to_location': 'query:files.unique_to_location',
'spaces.get_layout': 'query:spaces.get_layout',
'files.content_kind_stats': 'query:files.content_kind_stats',
'locations.validate_path': 'query:locations.validate_path',
'tags.search': 'query:tags.search',
'sync.eventLog': 'query:sync.eventLog',
'search.files': 'query:search.files',
'files.unique_to_location': 'query:files.unique_to_location',
'files.directory_listing': 'query:files.directory_listing',
'test.ping': 'query:test.ping',
'locations.suggested': 'query:locations.suggested',
'spaces.get': 'query:spaces.get',
'locations.list': 'query:locations.list',
'files.by_path': 'query:files.by_path',
'sync.activity': 'query:sync.activity',
'sync.metrics': 'query:sync.metrics',
'jobs.info': 'query:jobs.info',
'files.media_listing': 'query:files.media_listing',
'files.directory_listing': 'query:files.directory_listing',
'locations.validate_path': 'query:locations.validate_path',
'locations.list': 'query:locations.list',
'sync.activity': 'query:sync.activity',
'search.files': 'query:search.files',
'jobs.active': 'query:jobs.active',
'libraries.info': 'query:libraries.info',
'sync.eventLog': 'query:sync.eventLog',
'volumes.list': 'query:volumes.list',
'devices.list': 'query:devices.list',
'spaces.list': 'query:spaces.list',
'files.media_listing': 'query:files.media_listing',
'jobs.list': 'query:jobs.list',
'libraries.info': 'query:libraries.info',
'files.by_id': 'query:files.by_id',
'spaces.get': 'query:spaces.get',
'tags.search': 'query:tags.search',
'locations.suggested': 'query:locations.suggested',
},
} as const;