diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67a28e164..929e17e9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,30 @@ jobs: - name: Perform typechecks run: pnpm typecheck + eslint: + name: ESLint + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Install pnpm + uses: pnpm/action-setup@v2.2.2 + with: + version: 7.x.x + + - name: Install Node.js + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: 'pnpm' + + - name: Install pnpm dependencies + run: pnpm i --frozen-lockfile + + - name: Perform linting + run: pnpm lint + rustfmt: name: rustfmt runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 81db9b416..af40c8c2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5477,15 +5477,30 @@ dependencies = [ "webp", ] +[[package]] +name = "sd-core-android" +version = "0.1.0" +dependencies = [ + "jni", + "sd-core-mobile", + "tracing", +] + +[[package]] +name = "sd-core-ios" +version = "0.1.0" +dependencies = [ + "objc", + "objc-foundation", + "objc_id", + "sd-core-mobile", +] + [[package]] name = "sd-core-mobile" version = "0.1.0" dependencies = [ "futures", - "jni", - "objc", - "objc-foundation", - "objc_id", "once_cell", "openssl", "openssl-sys", diff --git a/Cargo.toml b/Cargo.toml index 698c9a4d0..edd9b02ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ # "crates/p2p/tunnel/utils", "apps/cli", "apps/desktop/src-tauri", - "apps/mobile/rust", + "apps/mobile/rust/*", "apps/server", ] diff --git a/apps/desktop/src-tauri/native/macos/Package.resolved b/apps/desktop/src-tauri/native/macos/Package.resolved index 3c5275e65..2fd3cde38 100644 --- a/apps/desktop/src-tauri/native/macos/Package.resolved +++ b/apps/desktop/src-tauri/native/macos/Package.resolved @@ -6,7 +6,7 @@ "repositoryURL": "https://github.com/brendonovich/swift-rs", "state": { "branch": null, - "revision": "833e29ba333f1dfe303eaa21de78c4f8c5a3f2ff", + "revision": "c3003bc0c28a6742d3da341b61887d8e072fda0a", "version": null } } diff --git a/apps/desktop/src-tauri/native/macos/Package.swift b/apps/desktop/src-tauri/native/macos/Package.swift index 2ea0a6845..c98047dfe 100644 --- a/apps/desktop/src-tauri/native/macos/Package.swift +++ b/apps/desktop/src-tauri/native/macos/Package.swift @@ -18,7 +18,7 @@ let package = Package( ], dependencies: [ // Dependencies declare other packages that this package depends on. - .package(url: "https://github.com/brendonovich/swift-rs", revision: "833e29ba333f1dfe303eaa21de78c4f8c5a3f2ff"), + .package(url: "https://github.com/brendonovich/swift-rs", revision: "c3003bc0c28a6742d3da341b61887d8e072fda0a"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 5de1b570c..24e8395df 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -17,8 +17,10 @@ use tauri::{ use tokio::task::block_in_place; use tokio::time::sleep; use tracing::{debug, error}; + #[cfg(target_os = "macos")] mod macos; + mod menu; #[tauri::command(async)] diff --git a/apps/landing/.eslintrc.js b/apps/landing/.eslintrc.js index 01aae4aa3..615ceae2b 100644 --- a/apps/landing/.eslintrc.js +++ b/apps/landing/.eslintrc.js @@ -1,8 +1,7 @@ module.exports = { - ...require('@sd/config/eslint-react.js'), + extends: [require.resolve('@sd/config/eslint/web.js')], parserOptions: { tsconfigRootDir: __dirname, project: './tsconfig.json' - }, - ignorePatterns: ['**/*.js', '**/*.json', 'node_modules', 'public', 'dist', 'vite.config.ts'] + } }; diff --git a/apps/landing/package.json b/apps/landing/package.json index 7a472f641..39b0b7c97 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -7,7 +7,7 @@ "build": "vite build", "server": "ts-node ./server", "server:prod": "cross-env NODE_ENV=production ts-node ./server", - "lint": "eslint src/**/*.{ts,tsx} && tsc --noEmit", + "lint": "eslint src", "typecheck": "tsc -b" }, "dependencies": { diff --git a/apps/landing/public/app-ui-explorer.webp b/apps/landing/public/app-ui-explorer.webp new file mode 100644 index 000000000..4f67d894e Binary files /dev/null and b/apps/landing/public/app-ui-explorer.webp differ diff --git a/apps/landing/public/database-table-plus.webp b/apps/landing/public/database-table-plus.webp new file mode 100644 index 000000000..b89a5b1e2 Binary files /dev/null and b/apps/landing/public/database-table-plus.webp differ diff --git a/apps/landing/public/schema.webp b/apps/landing/public/schema.webp new file mode 100644 index 000000000..b4be52706 Binary files /dev/null and b/apps/landing/public/schema.webp differ diff --git a/apps/landing/src/components/Footer.tsx b/apps/landing/src/components/Footer.tsx index 8c01ee31a..6dfb709d2 100644 --- a/apps/landing/src/components/Footer.tsx +++ b/apps/landing/src/components/Footer.tsx @@ -61,7 +61,7 @@ export function Footer() { Team FAQ Careers - Changelog + Changelog Blog
@@ -98,11 +98,11 @@ export function Footer() { License -
- Privacy +
+ Privacy
-
- Terms +
+ Terms
diff --git a/apps/mobile/.eslintrc.js b/apps/mobile/.eslintrc.js index 11305df15..cd041390b 100644 --- a/apps/mobile/.eslintrc.js +++ b/apps/mobile/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - ...require('@sd/config/eslint-react-native.js'), + extends: [require.resolve('@sd/config/eslint/reactNative.js')], parserOptions: { tsconfigRootDir: __dirname, project: './tsconfig.json' diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 1433bd53c..37a827676 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -6,8 +6,8 @@ import org.apache.tools.ant.taskdefs.condition.Os apply plugin: 'org.mozilla.rust-android-gradle.rust-android' cargo { - module = "../../rust" - libname = "sd_core_mobile" + module = "../../rust/android" + libname = "sd_core_android" pythonCommand = 'python3' profile = 'release' targets = ["arm", "arm64", "x86", "x86_64"] diff --git a/apps/mobile/android/app/src/main/java/com/spacedrive/app/SDCore.java b/apps/mobile/android/app/src/main/java/com/spacedrive/app/SDCore.java index d6c9f011d..9be5d33eb 100644 --- a/apps/mobile/android/app/src/main/java/com/spacedrive/app/SDCore.java +++ b/apps/mobile/android/app/src/main/java/com/spacedrive/app/SDCore.java @@ -28,7 +28,7 @@ public class SDCore extends ReactContextBaseJavaModule { } static { - System.loadLibrary("sd_core_mobile"); + System.loadLibrary("sd_core_android"); } // is exposed by Rust and is used to register the subscription @@ -78,4 +78,4 @@ public class SDCore extends ReactContextBaseJavaModule { .emit("SDCoreEvent", body); } } -} \ No newline at end of file +} diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index 478c112c2..cf0e6258b 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -12,11 +12,11 @@ PODS: - EXMediaLibrary (15.0.0): - ExpoModulesCore - React-Core - - Expo (47.0.10): + - Expo (47.0.13): - ExpoModulesCore - ExpoKeepAwake (11.0.1): - ExpoModulesCore - - ExpoModulesCore (1.1.0): + - ExpoModulesCore (1.1.1): - React-Core - ReactCommon/turbomodule/core - EXSplashScreen (0.17.5): @@ -564,9 +564,9 @@ SPEC CHECKSUMS: EXFileSystem: 60602b6eefa6873f97172c684b7537c9760b50d6 EXFont: 319606bfe48c33b5b5063fb0994afdc496befe80 EXMediaLibrary: b1c4f78878e45f6a359aff3a059e1660c41b73ab - Expo: a694d89d2461fdfc6b977bf489bf7d341ed03bca + Expo: b9fa98bf260992312ee3c424400819fb9beadafe ExpoKeepAwake: 69b59d0a8d2b24de9f82759c39b3821fec030318 - ExpoModulesCore: fc7e27657bc33878e1451c30cef481020518f2e1 + ExpoModulesCore: 65ae09e2b2d3dd8ece30a5acc83c569968125ab0 EXSplashScreen: 3e989924f61a8dd07ee4ea584c6ba14be9b51949 FBLazyVector: affa4ba1bfdaac110a789192f4d452b053a86624 FBReactNativeSpec: fe8b5f1429cfe83a8d72dc8ed61dc7704cac8745 diff --git a/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj b/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj index 5425054ec..2c8edf54e 100644 --- a/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Spacedrive.xcodeproj/project.pbxproj @@ -148,7 +148,7 @@ buildPhases = ( EE23FABC21723647AF9773BD /* [CP] Check Pods Manifest.lock */, FD10A7F022414F080027D42C /* Start Packager */, - 55B1130D28AB3061006C377F /* Build Spacedrive Core */, + 350DC403297BF2B8009CD6A1 /* Build sd-core-ios */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, @@ -224,6 +224,25 @@ shellPath = /bin/sh; shellScript = "export NODE_BINARY=node\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\n`node --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n"; }; + 350DC403297BF2B8009CD6A1 /* Build sd-core-ios */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build sd-core-ios"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/zsh; + shellScript = "env -i CONFIGURATION=$CONFIGURATION PLATFORM_NAME=$PLATFORM_NAME ./build-rust.sh\n"; + }; 37F7445AEFB5A9FBEDBB3916 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -244,29 +263,6 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Spacedrive/Pods-Spacedrive-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 55B1130D28AB3061006C377F /* Build Spacedrive Core */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "../../../core/src/*.rs", - "../rust/src/*.rs", - "../../../core/src/*/*.rs", - ); - name = "Build Spacedrive Core"; - outputFileListPaths = ( - ); - outputPaths = ( - "../../../target/sdcore-universal-ios.a", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/zsh; - shellScript = "set -e\n\nif [[ -n \"${DEVELOPER_SDK_DIR:-}\" ]]; then\n # Assume we're in Xcode, which means we're probably cross-compiling.\n # In this case, we need to add an extra library search path for build scripts and proc-macros,\n # which run on the host instead of the target.\n # (macOS Big Sur does not have linkable libraries in /usr/lib/.)\n export LIBRARY_PATH=\"${DEVELOPER_SDK_DIR}/MacOSX.sdk/usr/lib:${LIBRARY_PATH:-}\"\nfi\n\nCARGO_FLAGS=\nif [[ \"$BUILDVARIANT\" != \"debug\" ]]; then\n CARGO_FLAGS=--release\nfi\n\nTARGET_DIRECTORY=../../../target\nif [[ $PLATFORM_NAME = \"iphonesimulator\" ]]\nthen\n cargo build -p sd-core-mobile $CARGO_FLAGS --lib --target aarch64-apple-ios-sim\n lipo -create -output $TARGET_DIRECTORY/libsd_core_mobile-iossim.a $TARGET_DIRECTORY/aarch64-apple-ios-sim/release/libsd_core_mobile.a\nelse\n cargo build -p sd-core-mobile $CARGO_FLAGS --lib --target aarch64-apple-ios\n lipo -create -output $TARGET_DIRECTORY/libsd_core_mobile-ios.a $TARGET_DIRECTORY/aarch64-apple-ios/release/libsd_core_mobile.a\nfi\n"; - }; EE23FABC21723647AF9773BD /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -424,13 +420,13 @@ "$(inherited)", "-ObjC", "-lc++", - "-lsd_core_mobile-ios", + "-lsd_core_ios-ios", ); "OTHER_LDFLAGS[sdk=iphonesimulator*]" = ( "$(inherited)", "-ObjC", "-lc++", - "-lsd_core_mobile-iossim", + "-lsd_core_ios-iossim", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; PRODUCT_BUNDLE_IDENTIFIER = com.spacedrive.app; @@ -519,13 +515,13 @@ "$(inherited)", "-ObjC", "-lc++", - "-lsd_core_mobile-ios", + "-lsd_core_ios-ios", ); "OTHER_LDFLAGS[sdk=iphonesimulator*]" = ( "$(inherited)", "-ObjC", "-lc++", - "-lsd_core_mobile-iossim", + "-lsd_core_ios-iossim", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = com.spacedrive.app; diff --git a/apps/mobile/ios/build-rust.sh b/apps/mobile/ios/build-rust.sh new file mode 100755 index 000000000..d1c07c290 --- /dev/null +++ b/apps/mobile/ios/build-rust.sh @@ -0,0 +1,19 @@ +#! /bin/zsh + +set -e + +TARGET_DIRECTORY=../../../target + +CARGO_FLAGS= +if [[ $CONFIGURATION != "Debug" ]]; then + CARGO_FLAGS=--release +fi + +if [[ $PLATFORM_NAME = "iphonesimulator" ]] +then + cargo build -p sd-core-ios --target aarch64-apple-ios-sim + lipo -create -output $TARGET_DIRECTORY/libsd_core_ios-iossim.a $TARGET_DIRECTORY/aarch64-apple-ios-sim/debug/libsd_core_ios.a +else + cargo build -p sd-core-ios --target aarch64-apple-ios + lipo -create -output $TARGET_DIRECTORY/libsd_core_ios-ios.a $TARGET_DIRECTORY/aarch64-apple-ios/debug/libsd_core_ios.a +fi diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 56aacbf7c..424e2f782 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -10,7 +10,7 @@ "ios": "expo run:ios", "xcode": "open ios/spacedrive.xcworkspace", "android-studio": "open -a '/Applications/Android Studio.app' ./android", - "lint": "eslint src/**/*.{ts,tsx} && tsc --noEmit", + "lint": "eslint src", "postinstall": "node scripts/postinstall.js", "typecheck": "tsc -b" }, diff --git a/apps/mobile/rust/Cargo.toml b/apps/mobile/rust/Cargo.toml deleted file mode 100644 index c7cf918b0..000000000 --- a/apps/mobile/rust/Cargo.toml +++ /dev/null @@ -1,39 +0,0 @@ -[package] -name = "sd-core-mobile" -version = "0.1.0" -edition = "2021" -rust-version = "1.64.0" - -[lib] -crate-type = ["staticlib", "cdylib"] # staticlib for IOS and cdylib for Android - -[dependencies] -once_cell = "1.15.0" -sd-core = { path = "../../../core", features = [ - "mobile", - "p2p", -], default-features = false } -rspc = { workspace = true } -serde_json = "1.0.85" -tokio = "1.21.2" -openssl = { version = "0.10.42", features = [ - "vendored", -] } # Override features of transitive dependencies -openssl-sys = { version = "0.9.76", features = [ - "vendored", -] } # Override features of transitive dependencies to support IOS Simulator on M1 -futures = "0.3.24" -tracing = "0.1.37" - -[target.'cfg(target_os = "ios")'.dependencies] -objc = "0.2.7" -objc_id = "0.1.1" -objc-foundation = "0.1.1" - -# This is `not(ios)` instead of `android` because of https://github.com/mozilla/rust-android-gradle/issues/93 -[target.'cfg(not(target_os = "ios"))'.dependencies] -jni = "0.19.0" - -[target.'cfg(not(target_os = "ios"))'.features] -default = ["sd-core/android"] - diff --git a/apps/mobile/rust/android/Cargo.toml b/apps/mobile/rust/android/Cargo.toml new file mode 100644 index 000000000..01c488c76 --- /dev/null +++ b/apps/mobile/rust/android/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sd-core-android" +version = "0.1.0" +edition = "2021" +rust-version = "1.64.0" + +[lib] +# Android can use dynamic linking since all FFI is done via JNI +crate-type = ["cdylib"] + +[dependencies] +# FFI +jni = "0.19.0" + +# Core mobile handling stuff +sd-core-mobile = { path = "../mobile", features = ["android"] } + +# Other +tracing = "0.1.37" diff --git a/apps/mobile/rust/android/src/lib.rs b/apps/mobile/rust/android/src/lib.rs new file mode 100644 index 000000000..b92d6b80a --- /dev/null +++ b/apps/mobile/rust/android/src/lib.rs @@ -0,0 +1,105 @@ +use std::panic; + +use jni::{ + objects::{JClass, JObject, JString}, + JNIEnv, +}; + +use sd_core_mobile::*; + +use tracing::error; + +#[no_mangle] +pub extern "system" fn Java_com_spacedrive_app_SDCore_registerCoreEventListener( + env: JNIEnv, + class: JClass, +) { + let result = panic::catch_unwind(|| { + let jvm = env.get_java_vm().unwrap(); + let class = env.new_global_ref(class).unwrap(); + + spawn_core_event_listener(move |data| { + let env = jvm.attach_current_thread().unwrap(); + env.call_method( + &class, + "sendCoreEvent", + "(Ljava/lang/String;)V", + &[env + .new_string(data) + .expect("Couldn't create java string!") + .into()], + ) + .unwrap(); + }) + }); + + if let Err(err) = result { + // TODO: Send rspc error or something here so we can show this in the UI. + // TODO: Maybe reinitialise the core cause it could be in an invalid state? + println!( + "Error in Java_com_spacedrive_app_SDCore_registerCoreEventListener: {:?}", + err + ); + } +} + +#[no_mangle] +pub extern "system" fn Java_com_spacedrive_app_SDCore_handleCoreMsg( + env: JNIEnv, + class: JClass, + query: JString, + callback: JObject, +) { + let result = panic::catch_unwind(|| { + let jvm = env.get_java_vm().unwrap(); + + let query: String = env + .get_string(query) + .expect("Couldn't get java string!") + .into(); + + let class = env.new_global_ref(class).unwrap(); + let callback = env.new_global_ref(callback).unwrap(); + + let data_directory = { + let env = jvm.attach_current_thread().unwrap(); + let data_dir = env + .call_method(&class, "getDataDirectory", "()Ljava/lang/String;", &[]) + .unwrap() + .l() + .unwrap(); + + env.get_string(data_dir.into()).unwrap().into() + }; + + handle_core_msg(query, data_directory, move |result| match result { + Ok(data) => { + let env = jvm.attach_current_thread().unwrap(); + env.call_method( + &callback, + "resolve", + "(Ljava/lang/Object;)V", + &[env + .new_string(data) + .expect("Couldn't create java string!") + .into()], + ) + .unwrap(); + } + Err(_) => { + // TODO: handle error + } + }); + }); + + if let Err(err) = result { + // TODO: Send rspc error or something here so we can show this in the UI. + // TODO: Maybe reinitialise the core cause it could be in an invalid state? + + // TODO: This log statement doesn't work. I recon the JNI env is being dropped before it's called. + error!( + "Error in Java_com_spacedrive_app_SDCore_registerCoreEventListener: {:?}", + err + ); + } +} diff --git a/apps/mobile/rust/ios/Cargo.toml b/apps/mobile/rust/ios/Cargo.toml new file mode 100644 index 000000000..26b034bad --- /dev/null +++ b/apps/mobile/rust/ios/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "sd-core-ios" +version = "0.1.0" +edition = "2021" + +rust-version = "1.64.0" + +[lib] +# iOS requires static linking +# Makes sense considering this lib needs to link against call_resolve and get_data_directory, +# which are only available when linking against the app's ObjC +crate-type = ["staticlib"] + + +[target.'cfg(target_os = "ios")'.dependencies] +# FFI +objc = "0.2.7" +objc_id = "0.1.1" +objc-foundation = "0.1.1" + +[dependencies] +# Core mobile handling stuff +sd-core-mobile = { path = "../mobile" } diff --git a/apps/mobile/rust/ios/src/lib.rs b/apps/mobile/rust/ios/src/lib.rs new file mode 100644 index 000000000..5fe98fb80 --- /dev/null +++ b/apps/mobile/rust/ios/src/lib.rs @@ -0,0 +1,81 @@ +#![cfg(target_os = "ios")] + +use std::{ + ffi::{CStr, CString}, + os::raw::{c_char, c_void}, + panic, +}; + +use objc::{msg_send, runtime::Object, sel, sel_impl}; +use objc_foundation::{INSString, NSString}; +use objc_id::Id; + +use sd_core_mobile::*; + +extern "C" { + fn get_data_directory() -> *const c_char; + fn call_resolve(resolve: *const c_void, result: *const c_char); +} + +// This struct wraps the function pointer which represent a Javascript Promise. We wrap the +// function pointers in a struct so we can unsafely assert to Rust that they are `Send`. +// We know they are send as we have ensured Objective-C won't deallocate the function pointer +// until `call_resolve` is called. +struct RNPromise(*const c_void); + +unsafe impl Send for RNPromise {} + +impl RNPromise { + // resolve the promise + unsafe fn resolve(self, result: CString) { + call_resolve(self.0, result.as_ptr()); + } +} + +#[no_mangle] +pub unsafe extern "C" fn register_core_event_listener(id: *mut Object) { + let result = panic::catch_unwind(|| { + let id = Id::::from_ptr(id); + + spawn_core_event_listener(move |data| { + let data = NSString::from_str(&data); + let _: () = msg_send![id, sendCoreEvent: data]; + }); + }); + + if let Err(err) = result { + // TODO: Send rspc error or something here so we can show this in the UI. + // TODO: Maybe reinitialise the core cause it could be in an invalid state? + println!("Error in register_core_event_listener: {:?}", err); + } +} + +#[no_mangle] +pub unsafe extern "C" fn sd_core_msg(query: *const c_char, resolve: *const c_void) { + let result = panic::catch_unwind(|| { + // This string is cloned to the Rust heap. This is important as Objective-C may remove the query once this function completions but prior to the async block finishing. + let query = CStr::from_ptr(query).to_str().unwrap().to_string(); + + let resolve = RNPromise(resolve); + + let data_directory = CStr::from_ptr(get_data_directory()) + .to_str() + .unwrap() + .to_string(); + + handle_core_msg(query, data_directory, |result| { + match result { + Ok(data) => resolve.resolve(CString::new(data).unwrap()), + Err(_) => { + // TODO: handle error + } + } + }); + }); + + if let Err(err) = result { + // TODO: Send rspc error or something here so we can show this in the UI. + // TODO: Maybe reinitialise the core cause it could be in an invalid state? + println!("Error in sd_core_msg: {:?}", err); + } +} diff --git a/apps/mobile/rust/mobile/Cargo.toml b/apps/mobile/rust/mobile/Cargo.toml new file mode 100644 index 000000000..6a755d243 --- /dev/null +++ b/apps/mobile/rust/mobile/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sd-core-mobile" +version = "0.1.0" +edition = "2021" +rust-version = "1.64.0" + +[features] +android = ["sd-core/android"] + +[dependencies] +once_cell = "1.15.0" +sd-core = { path = "../../../../core", features = [ + "mobile", + "p2p", +], default-features = false } +rspc.workspace= true +serde_json = "1.0.85" +tokio = "1.21.2" +openssl = { version = "0.10.42", features = [ + "vendored", +] } # Override features of transitive dependencies +openssl-sys = { version = "0.9.76", features = [ + "vendored", +] } # Override features of transitive dependencies to support IOS Simulator on M1 +futures = "0.3.24" +tracing = "0.1.37" diff --git a/apps/mobile/rust/mobile/src/lib.rs b/apps/mobile/rust/mobile/src/lib.rs new file mode 100644 index 000000000..362475845 --- /dev/null +++ b/apps/mobile/rust/mobile/src/lib.rs @@ -0,0 +1,106 @@ +use futures::future::join_all; +use once_cell::sync::{Lazy, OnceCell}; +use rspc::internal::jsonrpc::*; +use sd_core::{api::Router, Node}; +use serde_json::{from_str, from_value, to_string, Value}; +use std::{collections::HashMap, marker::Send, sync::Arc}; +use tokio::{ + runtime::Runtime, + sync::{ + mpsc::{unbounded_channel, UnboundedSender}, + oneshot, Mutex, + }, +}; +use tracing::error; + +pub static RUNTIME: Lazy = Lazy::new(|| Runtime::new().unwrap()); + +pub type NodeType = Lazy, Arc)>>>; + +pub static NODE: NodeType = Lazy::new(|| Mutex::new(None)); + +pub static SUBSCRIPTIONS: Lazy>>> = + Lazy::new(Default::default); + +pub static EVENT_SENDER: OnceCell> = OnceCell::new(); + +pub fn handle_core_msg( + query: String, + data_dir: String, + callback: impl FnOnce(Result) + Send + 'static, +) { + RUNTIME.spawn(async move { + let (node, router) = { + let node = &mut *NODE.lock().await; + match node { + Some(node) => node.clone(), + None => { + // TODO: probably don't unwrap + let new_node = Node::new(data_dir).await.unwrap(); + node.replace(new_node.clone()); + new_node + } + } + }; + + let reqs = match from_str::(&query).and_then(|v| match v.is_array() { + true => from_value::>(v), + false => from_value::(v).map(|v| vec![v]), + }) { + Ok(v) => v, + Err(err) => { + error!("failed to decode JSON-RPC request: {}", err); // Don't use tracing here because it's before the `Node` is initialised which sets that config! + callback(Err(query)); + return; + } + }; + + let responses = join_all(reqs.into_iter().map(|request| { + let node = node.clone(); + let router = router.clone(); + async move { + let mut channel = EVENT_SENDER.get().unwrap().clone(); + let mut resp = Sender::ResponseAndChannel(None, &mut channel); + + handle_json_rpc( + node.get_request_context(), + request, + &router, + &mut resp, + &mut SubscriptionMap::Mutex(&SUBSCRIPTIONS), + ) + .await; + + match resp { + Sender::ResponseAndChannel(resp, _) => resp, + _ => unreachable!(), + } + } + })) + .await; + + callback(Ok(serde_json::to_string( + &responses.into_iter().flatten().collect::>(), + ) + .unwrap())); + }); +} + +pub fn spawn_core_event_listener(callback: impl Fn(String) + Send + 'static) { + let (tx, mut rx) = unbounded_channel(); + let _ = EVENT_SENDER.set(tx); + + RUNTIME.spawn(async move { + while let Some(event) = rx.recv().await { + let data = match to_string(&event) { + Ok(json) => json, + Err(err) => { + println!("Failed to serialize event: {}", err); + continue; + } + }; + + callback(data); + } + }); +} diff --git a/apps/mobile/rust/src/android.rs b/apps/mobile/rust/src/android.rs deleted file mode 100644 index 228dba91c..000000000 --- a/apps/mobile/rust/src/android.rs +++ /dev/null @@ -1,177 +0,0 @@ -use std::panic; - -use crate::{EVENT_SENDER, NODE, RUNTIME, SUBSCRIPTIONS}; -use futures::future::join_all; -use jni::objects::{JClass, JObject, JString}; -use jni::JNIEnv; -use rspc::internal::jsonrpc::{handle_json_rpc, Request, Sender, SubscriptionMap}; -use sd_core::Node; -use serde_json::Value; -use tokio::sync::mpsc::unbounded_channel; -use tracing::{error, info}; - -#[no_mangle] -pub extern "system" fn Java_com_spacedrive_app_SDCore_registerCoreEventListener( - env: JNIEnv, - class: JClass, -) { - let result = panic::catch_unwind(|| { - let jvm = env.get_java_vm().unwrap(); - let class = env.new_global_ref(class).unwrap(); - let (tx, mut rx) = unbounded_channel(); - let _ = EVENT_SENDER.set(tx); - - RUNTIME.spawn(async move { - while let Some(event) = rx.recv().await { - let data = match serde_json::to_string(&event) { - Ok(json) => json, - Err(err) => { - println!("Failed to serialize event: {}", err); - continue; - } - }; - - let env = jvm.attach_current_thread().unwrap(); - env.call_method( - &class, - "sendCoreEvent", - "(Ljava/lang/String;)V", - &[env - .new_string(data) - .expect("Couldn't create java string!") - .into()], - ) - .unwrap(); - } - }); - }); - - if let Err(err) = result { - // TODO: Send rspc error or something here so we can show this in the UI. - // TODO: Maybe reinitialise the core cause it could be in an invalid state? - println!( - "Error in Java_com_spacedrive_app_SDCore_registerCoreEventListener: {:?}", - err - ); - } -} - -#[no_mangle] -pub extern "system" fn Java_com_spacedrive_app_SDCore_handleCoreMsg( - env: JNIEnv, - class: JClass, - query: JString, - callback: JObject, -) { - let result = panic::catch_unwind(|| { - let jvm = env.get_java_vm().unwrap(); - let query: String = env - .get_string(query) - .expect("Couldn't get java string!") - .into(); - let class = env.new_global_ref(class).unwrap(); - let callback = env.new_global_ref(callback).unwrap(); - - RUNTIME.spawn(async move { - let (node, router) = { - let node = &mut *NODE.lock().await; - match node { - Some(node) => node.clone(), - None => { - let data_dir: String = { - let env = jvm.attach_current_thread().unwrap(); - let data_dir = env - .call_method( - &class, - "getDataDirectory", - "()Ljava/lang/String;", - &[], - ) - .unwrap() - .l() - .unwrap(); - - env.get_string(data_dir.into()).unwrap().into() - }; - - let new_node = Node::new(data_dir).await; - let new_node = match new_node { - Ok(new_node) => new_node, - Err(err) => { - info!("677 {:?}", err); - - // TODO: Android return? - return; - } - }; - - node.replace(new_node.clone()); - new_node - } - } - }; - - let reqs = - match serde_json::from_str::(&query).and_then(|v| match v.is_array() { - true => serde_json::from_value::>(v), - false => serde_json::from_value::(v).map(|v| vec![v]), - }) { - Ok(v) => v, - Err(err) => { - error!("failed to decode JSON-RPC request: {}", err); // Don't use tracing here because it's before the `Node` is initialised which sets that config! - return; - } - }; - - let resps = join_all(reqs.into_iter().map(|request| { - let node = node.clone(); - let router = router.clone(); - async move { - let mut channel = EVENT_SENDER.get().unwrap().clone(); - let mut resp = Sender::ResponseAndChannel(None, &mut channel); - - handle_json_rpc( - node.get_request_context(), - request, - &router, - &mut resp, - &mut SubscriptionMap::Mutex(&SUBSCRIPTIONS), - ) - .await; - - match resp { - Sender::ResponseAndChannel(resp, _) => resp, - _ => unreachable!(), - } - } - })) - .await; - - let env = jvm.attach_current_thread().unwrap(); - env.call_method( - &callback, - "resolve", - "(Ljava/lang/Object;)V", - &[env - .new_string( - serde_json::to_string(&resps.into_iter().flatten().collect::>()) - .unwrap(), - ) - .expect("Couldn't create java string!") - .into()], - ) - .unwrap(); - }); - }); - - if let Err(err) = result { - // TODO: Send rspc error or something here so we can show this in the UI. - // TODO: Maybe reinitialise the core cause it could be in an invalid state? - - // TODO: This log statement doesn't work. I recon the JNI env is being dropped before it's called. - error!( - "Error in Java_com_spacedrive_app_SDCore_registerCoreEventListener: {:?}", - err - ); - } -} diff --git a/apps/mobile/rust/src/ios.rs b/apps/mobile/rust/src/ios.rs deleted file mode 100644 index d17df064e..000000000 --- a/apps/mobile/rust/src/ios.rs +++ /dev/null @@ -1,139 +0,0 @@ -use crate::{EVENT_SENDER, NODE, RUNTIME, SUBSCRIPTIONS}; -use futures::future::join_all; -use objc::{msg_send, runtime::Object, sel, sel_impl}; -use objc_foundation::{INSString, NSString}; -use objc_id::Id; -use rspc::internal::jsonrpc::{handle_json_rpc, Request, Sender, SubscriptionMap}; -use sd_core::Node; -use serde_json::Value; -use std::{ - ffi::{CStr, CString}, - os::raw::{c_char, c_void}, - panic, -}; -use tokio::sync::mpsc::unbounded_channel; - -extern "C" { - fn get_data_directory() -> *const c_char; - fn call_resolve(resolve: *const c_void, result: *const c_char); -} - -// This struct wraps the function pointer which represent a Javascript Promise. We wrap the -// function pointers in a struct so we can unsafely assert to Rust that they are `Send`. -// We know they are send as we have ensured Objective-C won't deallocate the function pointer -// until `call_resolve` is called. -struct RNPromise(*const c_void); - -unsafe impl Send for RNPromise {} - -impl RNPromise { - // resolve the promise - unsafe fn resolve(self, result: CString) { - call_resolve(self.0, result.as_ptr()); - } -} - -#[no_mangle] -pub unsafe extern "C" fn register_core_event_listener(id: *mut Object) { - let result = panic::catch_unwind(|| { - let id = Id::::from_ptr(id); - - let (tx, mut rx) = unbounded_channel(); - let _ = EVENT_SENDER.set(tx); - - RUNTIME.spawn(async move { - while let Some(event) = rx.recv().await { - let data = match serde_json::to_string(&event) { - Ok(json) => json, - Err(err) => { - println!("Failed to serialize event: {}", err); - continue; - } - }; - let data = NSString::from_str(&data); - let _: () = msg_send![id, sendCoreEvent: data]; - } - }); - }); - - if let Err(err) = result { - // TODO: Send rspc error or something here so we can show this in the UI. - // TODO: Maybe reinitialise the core cause it could be in an invalid state? - println!("Error in register_core_event_listener: {:?}", err); - } -} - -#[no_mangle] -pub unsafe extern "C" fn sd_core_msg(query: *const c_char, resolve: *const c_void) { - let result = panic::catch_unwind(|| { - // This string is cloned to the Rust heap. This is important as Objective-C may remove the query once this function completions but prior to the async block finishing. - let query = CStr::from_ptr(query).to_str().unwrap().to_string(); - - let resolve = RNPromise(resolve); - RUNTIME.spawn(async move { - let reqs = - match serde_json::from_str::(&query).and_then(|v| match v.is_array() { - true => serde_json::from_value::>(v), - false => serde_json::from_value::(v).map(|v| vec![v]), - }) { - Ok(v) => v, - Err(err) => { - println!("failed to decode JSON-RPC request: {}", err); // Don't use tracing here because it's before the `Node` is initialised which sets that config! - - resolve.resolve( - CString::new(serde_json::to_vec(&(vec![] as Vec)).unwrap()) - .unwrap(), - ); // TODO: Proper error handling - return; - } - }; - - let resps = join_all(reqs.into_iter().map(|request| async move { - let node = &mut *NODE.lock().await; - let (node, router) = match node { - Some(node) => node.clone(), - None => { - let data_dir = CStr::from_ptr(get_data_directory()) - .to_str() - .unwrap() - .to_string(); - let new_node = Node::new(data_dir).await.unwrap(); - node.replace(new_node.clone()); - new_node - } - }; - - let mut channel = EVENT_SENDER.get().unwrap().clone(); - let mut resp = Sender::ResponseAndChannel(None, &mut channel); - handle_json_rpc( - node.get_request_context(), - request, - &router, - &mut resp, - &mut SubscriptionMap::Mutex(&SUBSCRIPTIONS), - ) - .await; - - match resp { - Sender::ResponseAndChannel(resp, _) => resp, - _ => unreachable!(), - } - })) - .await; - - resolve.resolve( - CString::new( - serde_json::to_vec(&resps.into_iter().filter_map(|v| v).collect::>()) - .unwrap(), - ) - .unwrap(), - ); - }); - }); - - if let Err(err) = result { - // TODO: Send rspc error or something here so we can show this in the UI. - // TODO: Maybe reinitialise the core cause it could be in an invalid state? - println!("Error in sd_core_msg: {:?}", err); - } -} diff --git a/apps/mobile/rust/src/lib.rs b/apps/mobile/rust/src/lib.rs deleted file mode 100644 index b887d887e..000000000 --- a/apps/mobile/rust/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -use std::{collections::HashMap, sync::Arc}; - -use once_cell::sync::{Lazy, OnceCell}; -use rspc::internal::jsonrpc::{RequestId, Response}; -use sd_core::{api::Router, Node}; -use tokio::{ - runtime::Runtime, - sync::{mpsc::UnboundedSender, oneshot, Mutex}, -}; - -#[allow(dead_code)] -pub(crate) static RUNTIME: Lazy = Lazy::new(|| Runtime::new().unwrap()); - -type NodeType = Lazy, Arc)>>>; - -#[allow(dead_code)] -pub(crate) static NODE: NodeType = Lazy::new(|| Mutex::new(None)); - -#[allow(dead_code)] -pub(crate) static SUBSCRIPTIONS: Lazy>>> = - Lazy::new(Default::default); - -#[allow(dead_code)] -pub(crate) static EVENT_SENDER: OnceCell> = OnceCell::new(); - -#[cfg(target_os = "ios")] -mod ios; - -/// This is `not(ios)` instead of `android` because of https://github.com/mozilla/rust-android-gradle/issues/93 -#[cfg(not(target_os = "ios"))] -mod android; diff --git a/core/src/api/files.rs b/core/src/api/files.rs index d669e5c4a..8ba8b413c 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -2,6 +2,8 @@ use crate::{ invalidate_query, job::Job, object::fs::{ + copy::{FileCopierJob, FileCopierJobInit}, + cut::{FileCutterJob, FileCutterJobInit}, decrypt::{FileDecryptorJob, FileDecryptorJobInit}, delete::{FileDeleterJob, FileDeleterJobInit}, encrypt::{FileEncryptorJob, FileEncryptorJobInit}, @@ -122,6 +124,30 @@ pub(crate) fn mount() -> RouterBuilder { library.spawn_job(Job::new(args, FileEraserJob {})).await; invalidate_query!(library, "locations.getExplorerData"); + Ok(()) + }) + }) + .library_mutation("duplicateFiles", |t| { + t(|_, args: FileCopierJobInit, library| async move { + library.spawn_job(Job::new(args, FileCopierJob {})).await; + invalidate_query!(library, "locations.getExplorerData"); + + Ok(()) + }) + }) + .library_mutation("copyFiles", |t| { + t(|_, args: FileCopierJobInit, library| async move { + library.spawn_job(Job::new(args, FileCopierJob {})).await; + invalidate_query!(library, "locations.getExplorerData"); + + Ok(()) + }) + }) + .library_mutation("cutFiles", |t| { + t(|_, args: FileCutterJobInit, library| async move { + library.spawn_job(Job::new(args, FileCutterJob {})).await; + invalidate_query!(library, "locations.getExplorerData"); + Ok(()) }) }) diff --git a/core/src/job/job_manager.rs b/core/src/job/job_manager.rs index ff25e21b7..2497246dc 100644 --- a/core/src/job/job_manager.rs +++ b/core/src/job/job_manager.rs @@ -4,6 +4,12 @@ use crate::{ library::LibraryContext, location::indexer::indexer_job::{IndexerJob, INDEXER_JOB_NAME}, object::{ + fs::{ + copy::{FileCopierJob, COPY_JOB_NAME}, + cut::{FileCutterJob, CUT_JOB_NAME}, + delete::{FileDeleterJob, DELETE_JOB_NAME}, + erase::{FileEraserJob, ERASE_JOB_NAME}, + }, identifier_job::full_identifier_job::{FullFileIdentifierJob, FULL_IDENTIFIER_JOB_NAME}, preview::{ThumbnailJob, THUMBNAIL_JOB_NAME}, validation::validator_job::{ObjectValidatorJob, VALIDATOR_JOB_NAME}, @@ -217,6 +223,26 @@ impl JobManager { .dispatch_job(ctx, Job::resume(paused_job, ObjectValidatorJob {})?) .await; } + CUT_JOB_NAME => { + Arc::clone(&self) + .dispatch_job(ctx, Job::resume(paused_job, FileCutterJob {})?) + .await; + } + COPY_JOB_NAME => { + Arc::clone(&self) + .dispatch_job(ctx, Job::resume(paused_job, FileCopierJob {})?) + .await; + } + DELETE_JOB_NAME => { + Arc::clone(&self) + .dispatch_job(ctx, Job::resume(paused_job, FileDeleterJob {})?) + .await; + } + ERASE_JOB_NAME => { + Arc::clone(&self) + .dispatch_job(ctx, Job::resume(paused_job, FileEraserJob {})?) + .await; + } _ => { error!( "Unknown job type: {}, id: {}", diff --git a/core/src/job/mod.rs b/core/src/job/mod.rs index dec1ff267..f378f0941 100644 --- a/core/src/job/mod.rs +++ b/core/src/job/mod.rs @@ -47,6 +47,10 @@ pub enum JobError { MissingData { value: String }, #[error("Location manager error: {0}")] LocationManager(#[from] LocationManagerError), + #[error("error converting/handling OS strings")] + OsStr, + #[error("error converting/handling paths")] + Path, // Specific job errors #[error("Indexer error: {0}")] diff --git a/core/src/object/fs/copy.rs b/core/src/object/fs/copy.rs index e69de29bb..eb85b9848 100644 --- a/core/src/object/fs/copy.rs +++ b/core/src/object/fs/copy.rs @@ -0,0 +1,177 @@ +use crate::job::{JobError, JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext}; + +use std::{hash::Hash, path::PathBuf}; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use tracing::trace; + +use super::{context_menu_fs_info, get_path_from_location_id, osstr_to_string, FsInfo, ObjectType}; + +pub struct FileCopierJob {} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FileCopierJobState { + pub target_path: PathBuf, // target dir prefix too + pub source_path: PathBuf, + pub root_type: ObjectType, +} + +#[derive(Serialize, Deserialize, Hash, Type)] +pub struct FileCopierJobInit { + pub source_location_id: i32, + pub source_path_id: i32, + pub target_location_id: i32, + pub target_path: PathBuf, + pub target_file_name_suffix: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct FileCopierJobStep { + pub source_fs_info: FsInfo, +} + +pub const COPY_JOB_NAME: &str = "file_copier"; + +#[async_trait::async_trait] +impl StatefulJob for FileCopierJob { + type Init = FileCopierJobInit; + type Data = FileCopierJobState; + type Step = FileCopierJobStep; + + fn name(&self) -> &'static str { + COPY_JOB_NAME + } + + async fn init(&self, ctx: WorkerContext, state: &mut JobState) -> Result<(), JobError> { + let source_fs_info = context_menu_fs_info( + &ctx.library_ctx.db, + state.init.source_location_id, + state.init.source_path_id, + ) + .await?; + + let mut full_target_path = + get_path_from_location_id(&ctx.library_ctx.db, state.init.target_location_id).await?; + + // add the currently viewed subdirectory to the location root + full_target_path.push(&state.init.target_path); + + // extension wizardry for cloning and such + // if no suffix has been selected, just use the file name + // if a suffix is provided and it's a directory, use the directory name + suffix + // if a suffix is provided and it's a file, use the (file name + suffix).extension + let file_name = osstr_to_string(source_fs_info.obj_path.file_name())?; + + let target_file_name = state.init.target_file_name_suffix.as_ref().map_or_else( + || Ok::<_, JobError>(file_name.clone()), + |s| match source_fs_info.obj_type { + ObjectType::Directory => Ok(format!("{file_name}{s}")), + ObjectType::File => Ok(osstr_to_string(source_fs_info.obj_path.file_stem())? + + s + &source_fs_info.obj_path.extension().map_or_else( + || Ok::<_, JobError>(String::new()), + |x| Ok(format!(".{}", x.to_str().ok_or(JobError::OsStr)?)), + )?), + }, + )?; + + full_target_path.push(target_file_name); + + state.data = Some(FileCopierJobState { + target_path: full_target_path, + source_path: source_fs_info.obj_path.clone(), + root_type: source_fs_info.obj_type.clone(), + }); + + state.steps = [FileCopierJobStep { source_fs_info }].into_iter().collect(); + + ctx.progress(vec![JobReportUpdate::TaskCount(state.steps.len())]); + + Ok(()) + } + + async fn execute_step( + &self, + ctx: WorkerContext, + state: &mut JobState, + ) -> Result<(), JobError> { + let step = &state.steps[0]; + let info = &step.source_fs_info; + + let job_state = state.data.as_ref().ok_or(JobError::MissingData { + value: String::from("job state"), + })?; + + match info.obj_type { + ObjectType::File => { + let mut path = job_state.target_path.clone(); + + if job_state.root_type == ObjectType::Directory { + // if root type is a dir, we need to preserve structure by making paths relative + path.push( + info.obj_path + .strip_prefix(&job_state.source_path) + .map_err(|_| JobError::Path)?, + ); + } + + trace!("Copying from {:?} to {:?}", info.obj_path, path); + + tokio::fs::copy(&info.obj_path, &path).await?; + } + ObjectType::Directory => { + // if this is the very first path, create the target dir + // fixes copying dirs with no child directories + if job_state.root_type == ObjectType::Directory + && job_state.source_path == info.obj_path + { + tokio::fs::create_dir_all(&job_state.target_path).await?; + } + + let mut dir = tokio::fs::read_dir(&info.obj_path).await?; + while let Some(entry) = dir.next_entry().await? { + if entry.metadata().await?.is_dir() { + state.steps.push_back(FileCopierJobStep { + source_fs_info: FsInfo { + obj_id: None, + obj_name: String::new(), + obj_path: entry.path(), + obj_type: ObjectType::Directory, + }, + }); + + tokio::fs::create_dir_all( + job_state.target_path.join( + entry + .path() + .strip_prefix(&job_state.source_path) + .map_err(|_| JobError::Path)?, + ), + ) + .await?; + } else { + state.steps.push_back(FileCopierJobStep { + source_fs_info: FsInfo { + obj_id: None, + obj_name: osstr_to_string(Some(&entry.file_name()))?, + obj_path: entry.path(), + obj_type: ObjectType::File, + }, + }); + }; + + ctx.progress(vec![JobReportUpdate::TaskCount(state.steps.len())]); + } + } + }; + + ctx.progress(vec![JobReportUpdate::CompletedTaskCount( + state.step_number + 1, + )]); + Ok(()) + } + + async fn finalize(&self, _ctx: WorkerContext, state: &mut JobState) -> JobResult { + Ok(Some(serde_json::to_value(&state.init)?)) + } +} diff --git a/core/src/object/fs/cut.rs b/core/src/object/fs/cut.rs new file mode 100644 index 000000000..733a8948e --- /dev/null +++ b/core/src/object/fs/cut.rs @@ -0,0 +1,91 @@ +use crate::job::{JobError, JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext}; + +use std::{hash::Hash, path::PathBuf}; + +use serde::{Deserialize, Serialize}; +use specta::Type; +use tracing::trace; + +use super::{context_menu_fs_info, get_path_from_location_id, FsInfo}; + +pub struct FileCutterJob {} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FileCutterJobState {} + +#[derive(Serialize, Deserialize, Hash, Type)] +pub struct FileCutterJobInit { + pub source_location_id: i32, + pub source_path_id: i32, + pub target_location_id: i32, + pub target_path: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct FileCutterJobStep { + pub source_fs_info: FsInfo, + pub target_directory: PathBuf, +} + +pub const CUT_JOB_NAME: &str = "file_cutter"; + +#[async_trait::async_trait] +impl StatefulJob for FileCutterJob { + type Init = FileCutterJobInit; + type Data = FileCutterJobState; + type Step = FileCutterJobStep; + + fn name(&self) -> &'static str { + CUT_JOB_NAME + } + + async fn init(&self, ctx: WorkerContext, state: &mut JobState) -> Result<(), JobError> { + let source_fs_info = context_menu_fs_info( + &ctx.library_ctx.db, + state.init.source_location_id, + state.init.source_path_id, + ) + .await?; + + let mut full_target_path = + get_path_from_location_id(&ctx.library_ctx.db, state.init.target_location_id).await?; + full_target_path.push(&state.init.target_path); + + state.steps = [FileCutterJobStep { + source_fs_info, + target_directory: full_target_path, + }] + .into_iter() + .collect(); + + ctx.progress(vec![JobReportUpdate::TaskCount(state.steps.len())]); + + Ok(()) + } + + async fn execute_step( + &self, + ctx: WorkerContext, + state: &mut JobState, + ) -> Result<(), JobError> { + let step = &state.steps[0]; + let source_info = &step.source_fs_info; + + let full_output = step + .target_directory + .join(source_info.obj_path.file_name().ok_or(JobError::OsStr)?); + + trace!("Cutting {:?} to {:?}", source_info.obj_path, full_output); + + tokio::fs::rename(&source_info.obj_path, &full_output).await?; + + ctx.progress(vec![JobReportUpdate::CompletedTaskCount( + state.step_number + 1, + )]); + Ok(()) + } + + async fn finalize(&self, _ctx: WorkerContext, state: &mut JobState) -> JobResult { + Ok(Some(serde_json::to_value(&state.init)?)) + } +} diff --git a/core/src/object/fs/delete.rs b/core/src/object/fs/delete.rs index 6cd020afa..0b564e890 100644 --- a/core/src/object/fs/delete.rs +++ b/core/src/object/fs/delete.rs @@ -20,7 +20,7 @@ pub struct FileDeleterJobStep { pub fs_info: FsInfo, } -const JOB_NAME: &str = "file_deleter"; +pub const DELETE_JOB_NAME: &str = "file_deleter"; #[async_trait::async_trait] impl StatefulJob for FileDeleterJob { @@ -29,7 +29,7 @@ impl StatefulJob for FileDeleterJob { type Step = FileDeleterJobStep; fn name(&self) -> &'static str { - JOB_NAME + DELETE_JOB_NAME } async fn init(&self, ctx: WorkerContext, state: &mut JobState) -> Result<(), JobError> { diff --git a/core/src/object/fs/erase.rs b/core/src/object/fs/erase.rs index 316965f89..d6d7b3f06 100644 --- a/core/src/object/fs/erase.rs +++ b/core/src/object/fs/erase.rs @@ -1,10 +1,13 @@ -use super::{context_menu_fs_info, FsInfo, ObjectType}; use crate::job::{JobError, JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext}; + +use std::{hash::Hash, path::PathBuf}; + use serde::{Deserialize, Serialize}; use specta::Type; -use std::{collections::VecDeque, hash::Hash, path::PathBuf}; use tokio::{fs::OpenOptions, io::AsyncWriteExt}; -use tracing::warn; +use tracing::{trace, warn}; + +use super::{context_menu_fs_info, osstr_to_string, FsInfo, ObjectType}; pub struct FileEraserJob {} @@ -26,16 +29,16 @@ pub struct FileEraserJobStep { pub fs_info: FsInfo, } -const JOB_NAME: &str = "file_eraser"; +pub const ERASE_JOB_NAME: &str = "file_eraser"; #[async_trait::async_trait] impl StatefulJob for FileEraserJob { - type Data = FileEraserJobState; type Init = FileEraserJobInit; + type Data = FileEraserJobState; type Step = FileEraserJobStep; fn name(&self) -> &'static str { - JOB_NAME + ERASE_JOB_NAME } async fn init(&self, ctx: WorkerContext, state: &mut JobState) -> Result<(), JobError> { @@ -51,8 +54,7 @@ impl StatefulJob for FileEraserJob { root_type: fs_info.obj_type.clone(), }); - state.steps = VecDeque::new(); - state.steps.push_back(FileEraserJobStep { fs_info }); + state.steps = [FileEraserJobStep { fs_info }].into_iter().collect(); ctx.progress(vec![JobReportUpdate::TaskCount(state.steps.len())]); @@ -64,7 +66,7 @@ impl StatefulJob for FileEraserJob { ctx: WorkerContext, state: &mut JobState, ) -> Result<(), JobError> { - let step = state.steps[0].clone(); + let step = &state.steps[0]; let info = &step.fs_info; // need to handle stuff such as querying prisma for all paths of a file, and deleting all of those if requested (with a checkbox in the ui) @@ -75,7 +77,7 @@ impl StatefulJob for FileEraserJob { let mut file = OpenOptions::new() .read(true) .write(true) - .open(info.obj_path.clone()) + .open(&info.obj_path) .await?; let file_len = file.metadata().await?.len(); @@ -85,29 +87,29 @@ impl StatefulJob for FileEraserJob { file.flush().await?; drop(file); - tokio::fs::remove_file(info.obj_path.clone()).await?; + trace!("Erasing file: {:?}", info.obj_path); + + tokio::fs::remove_file(&info.obj_path).await?; } ObjectType::Directory => { let mut dir = tokio::fs::read_dir(&info.obj_path).await?; while let Some(entry) = dir.next_entry().await? { if entry.metadata().await?.is_dir() { - let obj_type = ObjectType::Directory; state.steps.push_back(FileEraserJobStep { fs_info: FsInfo { obj_id: None, obj_name: String::new(), obj_path: entry.path(), - obj_type, + obj_type: ObjectType::Directory, }, }); } else { - let obj_type = ObjectType::File; state.steps.push_back(FileEraserJobStep { fs_info: FsInfo { obj_id: None, - obj_name: entry.file_name().to_str().unwrap().to_string(), + obj_name: osstr_to_string(Some(&entry.file_name()))?, obj_path: entry.path(), - obj_type, + obj_type: ObjectType::File, }, }); }; @@ -124,9 +126,9 @@ impl StatefulJob for FileEraserJob { } async fn finalize(&self, _ctx: WorkerContext, state: &mut JobState) -> JobResult { - if let Some(info) = state.data.clone() { + if let Some(ref info) = state.data { if info.root_type == ObjectType::Directory { - tokio::fs::remove_dir_all(info.root_path).await?; + tokio::fs::remove_dir_all(&info.root_path).await?; } } else { warn!("missing job state, unable to fully finalise erase job"); diff --git a/core/src/object/fs/mod.rs b/core/src/object/fs/mod.rs index a7a94c909..5677afd4f 100644 --- a/core/src/object/fs/mod.rs +++ b/core/src/object/fs/mod.rs @@ -1,12 +1,14 @@ -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - use crate::{ job::JobError, prisma::{file_path, location, PrismaClient}, }; +use std::{ffi::OsStr, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +pub mod copy; +pub mod cut; pub mod decrypt; pub mod delete; pub mod encrypt; @@ -26,11 +28,20 @@ pub struct FsInfo { pub obj_type: ObjectType, } -pub async fn context_menu_fs_info( +pub fn osstr_to_string(os_str: Option<&OsStr>) -> Result { + let string = os_str + .ok_or(JobError::OsStr)? + .to_str() + .ok_or(JobError::OsStr)? + .to_string(); + + Ok(string) +} + +pub async fn get_path_from_location_id( db: &PrismaClient, location_id: i32, - path_id: i32, -) -> Result { +) -> Result { let location = db .location() .find_unique(location::id::equals(location_id)) @@ -40,6 +51,22 @@ pub async fn context_menu_fs_info( value: String::from("location which matches location_id"), })?; + location + .local_path + .as_ref() + .map(PathBuf::from) + .ok_or(JobError::MissingData { + value: String::from("path when cast as `PathBuf`"), + }) +} + +pub async fn context_menu_fs_info( + db: &PrismaClient, + location_id: i32, + path_id: i32, +) -> Result { + let location_path = get_path_from_location_id(db, location_id).await?; + let item = db .file_path() .find_unique(file_path::location_id_id(location_id, path_id)) @@ -49,18 +76,7 @@ pub async fn context_menu_fs_info( value: String::from("file_path that matches both location id and path id"), })?; - let obj_path = [ - location - .local_path - .as_ref() - .map(PathBuf::from) - .ok_or(JobError::MissingData { - value: String::from("path when cast as `PathBuf`"), - })?, - item.materialized_path.clone().into(), - ] - .iter() - .collect(); + let obj_path = location_path.join(&item.materialized_path); // i don't know if this covers symlinks let obj_type = if item.is_dir { diff --git a/core/src/object/fs/move.rs b/core/src/object/fs/move.rs deleted file mode 100644 index e69de29bb..000000000 diff --git a/crates/crypto/src/fs/erase.rs b/crates/crypto/src/fs/erase.rs index 9aa95f996..902ff2301 100644 --- a/crates/crypto/src/fs/erase.rs +++ b/crates/crypto/src/fs/erase.rs @@ -1,8 +1,8 @@ +use crate::{primitives::BLOCK_SIZE, Result}; + use rand::{RngCore, SeedableRng}; use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; -use crate::{primitives::BLOCK_SIZE, Result}; - /// This is used for erasing a file. /// /// It requires the file size, a stream and the amount of passes (to overwrite the entire stream with random data) @@ -16,7 +16,7 @@ use crate::{primitives::BLOCK_SIZE, Result}; /// This also does not factor in temporary files, caching, thumbnails, etc. pub async fn erase(stream: &mut RW, size: usize, passes: usize) -> Result<()> where - RW: AsyncReadExt + AsyncWriteExt + AsyncSeekExt + Unpin, + RW: AsyncReadExt + AsyncWriteExt + AsyncSeekExt + Unpin + Send, { let block_count = size / BLOCK_SIZE; let additional = size % BLOCK_SIZE; diff --git a/docs/developers/architecture/database.md b/docs/developers/architecture/database.md index db564c97a..a0db0c78c 100644 --- a/docs/developers/architecture/database.md +++ b/docs/developers/architecture/database.md @@ -1,7 +1,24 @@ --- -index: 4 +index: 10 --- # Database -prisma client rust, sqlite, migrations, backup +### Schema + +Our data schema is defined using Prisma, you can [view it on GitHub](https://github.com/spacedriveapp/spacedrive/blob/main/core/prisma/schema.prisma). +![A cool screenshot of the Spacedrive schema](/schema.webp) + +### Prisma Client Rust + +We use Prisma Client Rust as a database ORM, it allows us to use Prisma to define our schema and generate migrations based on modifications to that schema. + +### Migrations + +Migrations are run by the Prisma migration engine on app launch. + +### Database file + +The databases file is SQLite and can be opened in any SQL viewer. + +![A Spacedrive library database file open in Table Plus](/database-table-plus.webp) diff --git a/docs/developers/architecture/explorer.md b/docs/developers/architecture/explorer.md index d6769f3bb..5bedae608 100644 --- a/docs/developers/architecture/explorer.md +++ b/docs/developers/architecture/explorer.md @@ -1,7 +1,17 @@ --- -index: 10 +index: 4 --- # Explorer -using the interface, features +### Grid view + +### List View + +### Columns View + +### Media View + +### Timeline View + +### Configuration diff --git a/docs/developers/architecture/extensions.md b/docs/developers/architecture/extensions.md index 258b0ec1a..8033bbd27 100644 --- a/docs/developers/architecture/extensions.md +++ b/docs/developers/architecture/extensions.md @@ -4,4 +4,4 @@ index: 10 # Extensions -extended functionality of Spacedrive +Extensions are planned but nothing is yet set in stone. diff --git a/docs/developers/architecture/libraries.md b/docs/developers/architecture/libraries.md index 7323fabc8..e152faf11 100644 --- a/docs/developers/architecture/libraries.md +++ b/docs/developers/architecture/libraries.md @@ -4,4 +4,8 @@ index: 1 # Libraries -A library is the database that Spacedrive stores all file structures and metadata. It can be synchronized with other [Nodes]() +A library is the database that Spacedrive stores all file structures and metadata. It can be synchronized with other [Nodes](). + +To learn how data is synchronized check out the documentation on [Sync](/docs/developers/architecture/sync). + +Libraries can be encrypted with a passphrase set by the user. diff --git a/docs/developers/architecture/spaces.md b/docs/developers/architecture/spaces.md index 618399ff8..bced47286 100644 --- a/docs/developers/architecture/spaces.md +++ b/docs/developers/architecture/spaces.md @@ -1,5 +1,5 @@ --- -index: 3 +index: 5 --- # Spaces diff --git a/docs/developers/clients/cli.md b/docs/developers/clients/cli.md index e8a524f9d..d1b7077da 100644 --- a/docs/developers/clients/cli.md +++ b/docs/developers/clients/cli.md @@ -1,3 +1,7 @@ --- index: 10 --- + +# CLI + +A CLI is planned, it would allow a user or application to connect and control a running Spacedrive node on a local or remote system. diff --git a/docs/developers/clients/rust.md b/docs/developers/clients/rust.md index e69de29bb..8634ea565 100644 --- a/docs/developers/clients/rust.md +++ b/docs/developers/clients/rust.md @@ -0,0 +1,7 @@ +--- +index: 10 +--- + +# Rust + +This doc should just show how to import the core and use it in a Rust context. diff --git a/docs/developers/prerequisites/welcome.md b/docs/developers/prerequisites/welcome.md index 71b5d9cfa..fd59a5c14 100644 --- a/docs/developers/prerequisites/welcome.md +++ b/docs/developers/prerequisites/welcome.md @@ -7,3 +7,5 @@ index: 0 ```rust pub struct DeveloperDocumentation; ``` + +This documentation is a work in progress, you will find unfinished or empty sections. diff --git a/docs/product/getting-started/introduction.md b/docs/product/getting-started/introduction.md index 49abbc9cf..7299f0069 100644 --- a/docs/product/getting-started/introduction.md +++ b/docs/product/getting-started/introduction.md @@ -3,7 +3,9 @@ name: Introduction index: 0 --- -# Welcome to Spacedrive +# Meet Spacedrive + +![image](/app-ui-explorer.webp) Spacedrive is a cross-platform file manager. It connects your devices together to help you organize files from anywhere. diff --git a/docs/product/getting-started/terminology.md b/docs/product/getting-started/terminology.md index 96a0c1f7f..6ae1a283e 100644 --- a/docs/product/getting-started/terminology.md +++ b/docs/product/getting-started/terminology.md @@ -4,3 +4,25 @@ index: 0 --- # Terminology + +Some useful Spacedrive related terminology. + +### `Library` + +A Library is the database that powers Spacedrive, all metadata and directory structures are saved in the Library. Libraries can be synced between devices and a user can have multiple libraries loaded in the Spacedrive app at once. [Learn more →](/docs/developers/architecture/libraries) + +### `Object` + +Objects are a fancy name for files, we call them Objects because we identify them uniquely based on a cryptographic hash of the contents. Objects in Spacedrive can come in a wide variety of kinds to provide a broad range of context. [Learn more →](/docs/developers/architecture/objects) + +### `Location` + +Locations are places Spacedrive will look for files, usually a directory on a mounted volume, but could also be a cloud service. [Learn more →](/docs/developers/architecture/locations) + +### `Node` + +A Node is a device or server running the Spacedrive core. Nodes can host libraries and communicate with other Nodes to sync data. [Learn more →](/docs/developers/architecture/nodes) + +### `Preview Media` + +Preview media refers to highly compressed image or video content for an Object, it is generated by Spacedrive and synced between your devices. [Learn more →](/docs/developers/architecture/preview-media) diff --git a/package.json b/package.json index d99727a08..229bc3599 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "client": "pnpm --filter @sd/client -- ", "prisma": "cd core && cargo prisma", "codegen": "cargo test -p sd-core api::tests::test_and_export_rspc_bindings -- --exact", - "typecheck": "turbo run typecheck" + "typecheck": "turbo run typecheck", + "lint": "turbo run lint", + "clean": "rimraf node_modules/ **/node_modules/ target/ **/.build/ **/.next/ **/dist/**" }, "pnpm": { "overrides": { @@ -38,6 +40,7 @@ "lint-staged": "^13.1.0", "markdown-link-check": "^3.10.3", "prettier": "^2.8.3", + "rimraf": "^4.1.1", "turbo": "^1.5.5", "turbo-ignore": "^0.3.0", "typescript": "^4.9.4" diff --git a/packages/client/.eslintrc.js b/packages/client/.eslintrc.js index b3e0f403d..615ceae2b 100644 --- a/packages/client/.eslintrc.js +++ b/packages/client/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - ...require('@sd/config/eslint-react.js'), + extends: [require.resolve('@sd/config/eslint/web.js')], parserOptions: { tsconfigRootDir: __dirname, project: './tsconfig.json' diff --git a/packages/client/package.json b/packages/client/package.json index 10dc94712..cb36007cd 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -8,7 +8,7 @@ ], "scripts": { "test": "jest", - "lint": "eslint src/**/*.{ts,tsx} && tsc --noEmit", + "lint": "eslint src", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", "typecheck": "tsc -b", "build": "tsc" diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index ad754226f..7fbe9283c 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -33,9 +33,12 @@ export type Procedures = { { key: "tags.list", input: LibraryArgs, result: Array } | { key: "volumes.list", input: never, result: Array }, mutations: + { key: "files.copyFiles", input: LibraryArgs, result: null } | + { key: "files.cutFiles", input: LibraryArgs, result: null } | { key: "files.decryptFiles", input: LibraryArgs, result: null } | { key: "files.delete", input: LibraryArgs, result: null } | { key: "files.deleteFiles", input: LibraryArgs, result: null } | + { key: "files.duplicateFiles", input: LibraryArgs, result: null } | { key: "files.encryptFiles", input: LibraryArgs, result: null } | { key: "files.eraseFiles", input: LibraryArgs, result: null } | { key: "files.setFavorite", input: LibraryArgs, result: null } | @@ -96,6 +99,10 @@ export interface ExplorerData { context: ExplorerContext, items: Array { const platform = usePlatform(); diff --git a/packages/interface/src/components/dialog/CreateLibraryDialog.tsx b/packages/interface/src/components/dialog/CreateLibraryDialog.tsx index e07eecb50..1310409b3 100644 --- a/packages/interface/src/components/dialog/CreateLibraryDialog.tsx +++ b/packages/interface/src/components/dialog/CreateLibraryDialog.tsx @@ -20,7 +20,7 @@ const schema = z.object({ hashing_algorithm: z.string() }); -interface Props extends UseDialogProps {} +type Props = UseDialogProps; export default function CreateLibraryDialog(props: Props) { const dialog = useDialog(props); diff --git a/packages/interface/src/components/dialog/KeyViewerDialog.tsx b/packages/interface/src/components/dialog/KeyViewerDialog.tsx index e5b8615bd..8d5821014 100644 --- a/packages/interface/src/components/dialog/KeyViewerDialog.tsx +++ b/packages/interface/src/components/dialog/KeyViewerDialog.tsx @@ -7,7 +7,7 @@ import { useZodForm, z } from '@sd/ui/src/forms'; import { getHashingAlgorithmString } from '~/screens/settings/library/KeysSetting'; import { SelectOptionKeyList } from '../key/KeyList'; -interface KeyViewerDialogProps extends UseDialogProps {} +type KeyViewerDialogProps = UseDialogProps; export const KeyUpdater = (props: { uuid: string; diff --git a/packages/interface/src/components/dialog/MasterPasswordChangeDialog.tsx b/packages/interface/src/components/dialog/MasterPasswordChangeDialog.tsx index 17c1b102f..9c1e7c483 100644 --- a/packages/interface/src/components/dialog/MasterPasswordChangeDialog.tsx +++ b/packages/interface/src/components/dialog/MasterPasswordChangeDialog.tsx @@ -9,7 +9,7 @@ import { showAlertDialog } from '~/util/dialog'; import { generatePassword } from '../key/KeyMounter'; import { PasswordMeter } from '../key/PasswordMeter'; -export interface MasterPasswordChangeDialogProps extends UseDialogProps {} +export type MasterPasswordChangeDialogProps = UseDialogProps; const schema = z.object({ masterPassword: z.string(), diff --git a/packages/interface/src/components/explorer/Explorer.tsx b/packages/interface/src/components/explorer/Explorer.tsx index 2b476e791..ecad2b6fc 100644 --- a/packages/interface/src/components/explorer/Explorer.tsx +++ b/packages/interface/src/components/explorer/Explorer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { ExplorerData, rspc, useCurrentLibrary } from '@sd/client'; import { useExplorerStore } from '~/hooks/useExplorerStore'; import { Inspector } from '../explorer/Inspector'; @@ -32,50 +32,50 @@ export default function Explorer(props: Props) { } }); - return ( - <> -
- -
- + const onScroll = useCallback((y: number) => { + setScrollSegments((old) => { + return { + ...old, + mainList: y + }; + }); + }, []); + + return ( +
+ +
+ + +
+ {props.data && ( + + )} + {expStore.showInspector && ( +
+ { + const y = (e.target as HTMLElement).scrollTop; -
- {props.data && ( - { setScrollSegments((old) => { return { ...old, - mainList: y + inspector: y }; }); }} + key={props.data?.items[expStore.selectedRowIndex]?.id} + data={props.data?.items[expStore.selectedRowIndex]} /> - )} - {expStore.showInspector && ( -
- { - const y = (e.target as HTMLElement).scrollTop; - - setScrollSegments((old) => { - return { - ...old, - inspector: y - }; - }); - }} - key={props.data?.items[expStore.selectedRowIndex]?.id} - data={props.data?.items[expStore.selectedRowIndex]} - /> -
- )} -
+
+ )}
- -
- +
+ +
); } diff --git a/packages/interface/src/components/explorer/ExplorerContextMenu.tsx b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx index 5fa4272b0..9b7af80d7 100644 --- a/packages/interface/src/components/explorer/ExplorerContextMenu.tsx +++ b/packages/interface/src/components/explorer/ExplorerContextMenu.tsx @@ -1,11 +1,15 @@ import { ArrowBendUpRight, + Clipboard, + Copy, + FileX, Image, LockSimple, LockSimpleOpen, Package, Plus, Repeat, + Scissors, Share, ShieldCheck, TagSimple, @@ -16,8 +20,9 @@ import { PropsWithChildren, useMemo } from 'react'; import { ExplorerItem, useLibraryMutation, useLibraryQuery } from '@sd/client'; import { ContextMenu as CM } from '@sd/ui'; import { dialogManager } from '@sd/ui'; -import { getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore'; +import { CutCopyType, getExplorerStore, useExplorerStore } from '~/hooks/useExplorerStore'; import { useOperatingSystem } from '~/hooks/useOperatingSystem'; +import { useExplorerParams } from '~/screens/LocationExplorer'; import { usePlatform } from '~/util/Platform'; import { showAlertDialog } from '~/util/dialog'; import { DecryptFileDialog } from '../dialog/DecryptFileDialog'; @@ -96,11 +101,14 @@ function OpenInNativeExplorer() { } export function ExplorerContextMenu(props: PropsWithChildren) { - const store = getExplorerStore(); + const store = useExplorerStore(); + const params = useExplorerParams(); const generateThumbsForLocation = useLibraryMutation('jobs.generateThumbsForLocation'); const objectValidator = useLibraryMutation('jobs.objectValidator'); const rescanLocation = useLibraryMutation('locations.fullRescan'); + const copyFiles = useLibraryMutation('files.copyFiles'); + const cutFiles = useLibraryMutation('files.cutFiles'); return (
@@ -131,6 +139,45 @@ export function ExplorerContextMenu(props: PropsWithChildren) { icon={Repeat} /> +
); -}; +}); interface WrappedItemProps { item: ExplorerItem; @@ -179,7 +179,7 @@ interface WrappedItemProps { } // Wrap either list item or grid item with click logic as it is the same for both -const WrappedItem: React.FC = ({ item, index, isSelected, kind }) => { +const WrappedItem = memo(({ item, index, isSelected, kind }: WrappedItemProps) => { const [_, setSearchParams] = useSearchParams(); const onDoubleClick = useCallback(() => { @@ -191,6 +191,7 @@ const WrappedItem: React.FC = ({ item, index, isSelected, kind }, [isSelected, index]); const ItemComponent = kind === 'list' ? FileRow : FileItem; + return ( = ({ item, index, isSelected, kind selected={isSelected} /> ); - - // // Memorize the item so that it doesn't get re-rendered when the selection changes - // return useMemo(() => { - // const ItemComponent = kind === 'list' ? FileRow : FileItem; - // return ( - // - // ); - // }, [item, index, isSelected]); -}; +}); diff --git a/packages/interface/src/components/jobs/JobManager.tsx b/packages/interface/src/components/jobs/JobManager.tsx index dc58d436b..abe9c6533 100644 --- a/packages/interface/src/components/jobs/JobManager.tsx +++ b/packages/interface/src/components/jobs/JobManager.tsx @@ -3,6 +3,8 @@ import dayjs from 'dayjs'; import { ArrowsClockwise, Camera, + Copy, + DotsThree, Eye, Fingerprint, Folder, @@ -10,6 +12,7 @@ import { LockSimpleOpen, Pause, Question, + Scissors, Trash, TrashSimple, X @@ -66,6 +69,18 @@ const getNiceData = (job: JobReport): Record => ({ job.task_count > 1 || job.task_count === 0 ? 'files' : 'file' }`, icon: Trash + }, + file_copier: { + name: `Copied ${numberWithCommas(job.task_count)} ${ + job.task_count > 1 || job.task_count === 0 ? 'files' : 'file' + }`, + icon: Copy + }, + file_cutter: { + name: `Moved ${numberWithCommas(job.task_count)} ${ + job.task_count > 1 || job.task_count === 0 ? 'files' : 'file' + }`, + icon: Scissors } }); diff --git a/packages/interface/src/hooks/useExplorerStore.tsx b/packages/interface/src/hooks/useExplorerStore.tsx index 15e957cf3..50adc596d 100644 --- a/packages/interface/src/hooks/useExplorerStore.tsx +++ b/packages/interface/src/hooks/useExplorerStore.tsx @@ -10,6 +10,11 @@ export enum ExplorerKind { Space } +export enum CutCopyType { + Cut, + Copy +} + const state = { locationId: null as number | null, layoutMode: 'grid' as ExplorerLayoutMode, @@ -21,7 +26,13 @@ const state = { multiSelectIndexes: [] as number[], contextMenuObjectId: null as number | null, contextMenuActiveObject: null as object | null, - newThumbnails: {} as Record + newThumbnails: {} as Record, + cutCopyState: { + sourceLocationId: 0, + sourcePathId: 0, + actionType: CutCopyType.Cut, + active: false + } }; onLibraryChange(() => getExplorerStore().reset()); diff --git a/packages/interface/src/screens/settings/node/LibrariesSettings.tsx b/packages/interface/src/screens/settings/node/LibrariesSettings.tsx index 3378b3340..e323ee1a9 100644 --- a/packages/interface/src/screens/settings/node/LibrariesSettings.tsx +++ b/packages/interface/src/screens/settings/node/LibrariesSettings.tsx @@ -22,7 +22,7 @@ function LibraryListItem(props: { library: LibraryConfigWrapped; current: boolea

{props.library.uuid}

- + return ( + + {to ? ( + + {content} + + ) : ( + + )} + ); }; diff --git a/packages/ui/src/Switch.tsx b/packages/ui/src/Switch.tsx index c1bc39d06..2be7d5888 100644 --- a/packages/ui/src/Switch.tsx +++ b/packages/ui/src/Switch.tsx @@ -4,7 +4,9 @@ import { forwardRef } from 'react'; export interface SwitchProps extends VariantProps, - SwitchPrimitive.SwitchProps {} + SwitchPrimitive.SwitchProps { + thumbClassName?: string; +} const switchStyles = cva( [ @@ -46,9 +48,9 @@ const thumbStyles = cva( ); export const Switch = forwardRef( - ({ size, className, ...props }, ref) => ( + ({ size, className, thumbClassName, ...props }, ref) => ( - + ) ); diff --git a/packages/ui/src/utils.tsx b/packages/ui/src/utils.tsx index 174a89b2f..27ffd0fd4 100644 --- a/packages/ui/src/utils.tsx +++ b/packages/ui/src/utils.tsx @@ -17,6 +17,7 @@ type TailwindFactory = { (c: T): ClassnameFactory; }; +// eslint-ignore-next-line export const tw = new Proxy((() => {}) as unknown as TailwindFactory, { get: (_, property: string) => twFactory(property), apply: (_, __, [el]: [React.ReactElement]) => twFactory(el) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bd26f605..915c4242f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,7 @@ importers: lint-staged: ^13.1.0 markdown-link-check: ^3.10.3 prettier: ^2.8.3 + rimraf: ^4.1.1 turbo: ^1.5.5 turbo-ignore: ^0.3.0 typescript: ^4.9.4 @@ -29,6 +30,7 @@ importers: lint-staged: 13.1.0 markdown-link-check: 3.10.3 prettier: 2.8.3 + rimraf: 4.1.1 turbo: 1.7.0 turbo-ignore: 0.3.0 typescript: 4.9.4 @@ -388,10 +390,11 @@ importers: packages/config: specifiers: - '@typescript-eslint/eslint-plugin': ^5.39.0 - '@typescript-eslint/parser': ^5.39.0 + '@typescript-eslint/eslint-plugin': ^5.48.2 + '@typescript-eslint/parser': ^5.48.2 eslint: ^8.24.0 eslint-config-prettier: ^8.5.0 + eslint-config-turbo: ^0.0.7 eslint-plugin-react: ^7.31.8 eslint-plugin-react-hooks: ^4.6.0 devDependencies: @@ -399,6 +402,7 @@ importers: '@typescript-eslint/parser': 5.48.2_eslint@8.32.0 eslint: 8.32.0 eslint-config-prettier: 8.6.0_eslint@8.32.0 + eslint-config-turbo: 0.0.7_eslint@8.32.0 eslint-plugin-react: 7.32.1_eslint@8.32.0 eslint-plugin-react-hooks: 4.6.0_eslint@8.32.0 @@ -421,7 +425,7 @@ importers: '@splinetool/runtime': ^0.9.128 '@tailwindcss/forms': ^0.5.3 '@tanstack/react-query': ^4.12.0 - '@tanstack/react-query-devtools': ^4.12.0 + '@tanstack/react-query-devtools': ^4.22.0 '@tanstack/react-virtual': 3.0.0-beta.18 '@types/babel-core': ^6.25.7 '@types/byte-size': ^8.1.0 @@ -8292,7 +8296,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.19.6_@babel+core@7.20.12 magic-string: 0.26.7 react-refresh: 0.14.0 - vite: 4.0.4_sass@1.57.1 + vite: 4.0.4_@types+node@18.11.18 transitivePeerDependencies: - supports-color @@ -11637,6 +11641,15 @@ packages: eslint: 8.32.0 dev: true + /eslint-config-turbo/0.0.7_eslint@8.32.0: + resolution: {integrity: sha512-WbrGlyfs94rOXrhombi1wjIAYGdV2iosgJRndOZtmDQeq5GLTzYmBUCJQZWtLBEBUPCj96RxZ2OL7Cn+xv/Azg==} + peerDependencies: + eslint: '>6.6.0' + dependencies: + eslint: 8.32.0 + eslint-plugin-turbo: 0.0.7_eslint@8.32.0 + dev: true + /eslint-plugin-react-hooks/4.6.0_eslint@8.32.0: resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} engines: {node: '>=10'} @@ -11685,6 +11698,14 @@ packages: string.prototype.matchall: 4.0.8 dev: true + /eslint-plugin-turbo/0.0.7_eslint@8.32.0: + resolution: {integrity: sha512-iajOH8eD4jha3duztGVBD1BEmvNrQBaA/y3HFHf91vMDRYRwH7BpHSDFtxydDpk5ghlhRxG299SFxz7D6z4MBQ==} + peerDependencies: + eslint: '>6.6.0' + dependencies: + eslint: 8.32.0 + dev: true + /eslint-scope/4.0.3: resolution: {integrity: sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==} engines: {node: '>=4.0.0'} @@ -18179,6 +18200,12 @@ packages: dependencies: glob: 7.2.3 + /rimraf/4.1.1: + resolution: {integrity: sha512-Z4Y81w8atcvaJuJuBB88VpADRH66okZAuEm+Jtaufa+s7rZmIz+Hik2G53kGaNytE7lsfXyWktTmfVz0H9xuDg==} + engines: {node: '>=14'} + hasBin: true + dev: true + /ripemd160/2.0.2: resolution: {integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==} dependencies: @@ -20804,7 +20831,7 @@ packages: dependencies: '@rollup/pluginutils': 5.0.2 '@svgr/core': 6.5.1 - vite: 4.0.4_sass@1.57.1 + vite: 4.0.4_@types+node@18.11.18 transitivePeerDependencies: - rollup - supports-color @@ -20900,7 +20927,6 @@ packages: rollup: 3.10.0 optionalDependencies: fsevents: 2.3.2 - dev: true /vite/4.0.4_ovmyjmuuyckt3r3gpaexj2onji: resolution: {integrity: sha512-xevPU7M8FU0i/80DMR+YhgrzR5KS2ORy1B4xcX/cXLsvnUWvfHuqMmVU6N0YiJ4JWGRJJsLCgjEzKjG9/GKoSw==} @@ -20968,6 +20994,7 @@ packages: sass: 1.57.1 optionalDependencies: fsevents: 2.3.2 + dev: true /vlq/1.0.1: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==}