mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-05 09:13:28 +00:00
Merge branch 'main' into closable-job-manager
This commit is contained in:
commit
8c4cdb1ede
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
|
@ -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
|
||||
|
|
23
Cargo.lock
generated
23
Cargo.lock
generated
|
@ -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",
|
||||
|
|
|
@ -7,7 +7,7 @@ members = [
|
|||
# "crates/p2p/tunnel/utils",
|
||||
"apps/cli",
|
||||
"apps/desktop/src-tauri",
|
||||
"apps/mobile/rust",
|
||||
"apps/mobile/rust/*",
|
||||
"apps/server",
|
||||
]
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
"repositoryURL": "https://github.com/brendonovich/swift-rs",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "833e29ba333f1dfe303eaa21de78c4f8c5a3f2ff",
|
||||
"revision": "c3003bc0c28a6742d3da341b61887d8e072fda0a",
|
||||
"version": null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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']
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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": {
|
||||
|
|
BIN
apps/landing/public/app-ui-explorer.webp
Normal file
BIN
apps/landing/public/app-ui-explorer.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
BIN
apps/landing/public/database-table-plus.webp
Normal file
BIN
apps/landing/public/database-table-plus.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 185 KiB |
BIN
apps/landing/public/schema.webp
Normal file
BIN
apps/landing/public/schema.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
|
@ -61,7 +61,7 @@ export function Footer() {
|
|||
<FooterLink link="/team">Team</FooterLink>
|
||||
<FooterLink link="/docs/product/resources/faq">FAQ</FooterLink>
|
||||
<FooterLink link="/careers">Careers</FooterLink>
|
||||
<FooterLink link="/changelog">Changelog</FooterLink>
|
||||
<FooterLink link="/docs/changelog/beta/0.1.0">Changelog</FooterLink>
|
||||
<FooterLink link="/blog">Blog</FooterLink>
|
||||
</div>
|
||||
<div className="flex flex-col col-span-1 space-y-2 pointer-events-none">
|
||||
|
@ -98,11 +98,11 @@ export function Footer() {
|
|||
<FooterLink blank link="https://github.com/spacedriveapp/spacedrive/blob/main/LICENSE">
|
||||
License
|
||||
</FooterLink>
|
||||
<div className="opacity-50 pointer-events-none">
|
||||
<FooterLink link="#">Privacy</FooterLink>
|
||||
<div>
|
||||
<FooterLink link="/docs/company/legal/privacy">Privacy</FooterLink>
|
||||
</div>
|
||||
<div className="opacity-50 pointer-events-none">
|
||||
<FooterLink link="#">Terms</FooterLink>
|
||||
<div>
|
||||
<FooterLink link="/docs/company/legal/terms">Terms</FooterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
19
apps/mobile/ios/build-rust.sh
Executable file
19
apps/mobile/ios/build-rust.sh
Executable file
|
@ -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
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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"]
|
||||
|
19
apps/mobile/rust/android/Cargo.toml
Normal file
19
apps/mobile/rust/android/Cargo.toml
Normal file
|
@ -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"
|
105
apps/mobile/rust/android/src/lib.rs
Normal file
105
apps/mobile/rust/android/src/lib.rs
Normal file
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
23
apps/mobile/rust/ios/Cargo.toml
Normal file
23
apps/mobile/rust/ios/Cargo.toml
Normal file
|
@ -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" }
|
81
apps/mobile/rust/ios/src/lib.rs
Normal file
81
apps/mobile/rust/ios/src/lib.rs
Normal file
|
@ -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::<Object>::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);
|
||||
}
|
||||
}
|
26
apps/mobile/rust/mobile/Cargo.toml
Normal file
26
apps/mobile/rust/mobile/Cargo.toml
Normal file
|
@ -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"
|
106
apps/mobile/rust/mobile/src/lib.rs
Normal file
106
apps/mobile/rust/mobile/src/lib.rs
Normal file
|
@ -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<Runtime> = Lazy::new(|| Runtime::new().unwrap());
|
||||
|
||||
pub type NodeType = Lazy<Mutex<Option<(Arc<Node>, Arc<Router>)>>>;
|
||||
|
||||
pub static NODE: NodeType = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
pub static SUBSCRIPTIONS: Lazy<Mutex<HashMap<RequestId, oneshot::Sender<()>>>> =
|
||||
Lazy::new(Default::default);
|
||||
|
||||
pub static EVENT_SENDER: OnceCell<UnboundedSender<Response>> = OnceCell::new();
|
||||
|
||||
pub fn handle_core_msg(
|
||||
query: String,
|
||||
data_dir: String,
|
||||
callback: impl FnOnce(Result<String, String>) + 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::<Value>(&query).and_then(|v| match v.is_array() {
|
||||
true => from_value::<Vec<Request>>(v),
|
||||
false => from_value::<Request>(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::<Vec<_>>(),
|
||||
)
|
||||
.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);
|
||||
}
|
||||
});
|
||||
}
|
|
@ -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::<Value>(&query).and_then(|v| match v.is_array() {
|
||||
true => serde_json::from_value::<Vec<Request>>(v),
|
||||
false => serde_json::from_value::<Request>(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::<Vec<_>>())
|
||||
.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
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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::<Object>::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::<Value>(&query).and_then(|v| match v.is_array() {
|
||||
true => serde_json::from_value::<Vec<Request>>(v),
|
||||
false => serde_json::from_value::<Request>(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<Request>)).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::<Vec<_>>())
|
||||
.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);
|
||||
}
|
||||
}
|
|
@ -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<Runtime> = Lazy::new(|| Runtime::new().unwrap());
|
||||
|
||||
type NodeType = Lazy<Mutex<Option<(Arc<Node>, Arc<Router>)>>>;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) static NODE: NodeType = Lazy::new(|| Mutex::new(None));
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) static SUBSCRIPTIONS: Lazy<Mutex<HashMap<RequestId, oneshot::Sender<()>>>> =
|
||||
Lazy::new(Default::default);
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) static EVENT_SENDER: OnceCell<UnboundedSender<Response>> = 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;
|
|
@ -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(())
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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: {}",
|
||||
|
|
|
@ -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}")]
|
||||
|
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<Self>) -> 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<Self>,
|
||||
) -> 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<Self>) -> JobResult {
|
||||
Ok(Some(serde_json::to_value(&state.init)?))
|
||||
}
|
||||
}
|
91
core/src/object/fs/cut.rs
Normal file
91
core/src/object/fs/cut.rs
Normal file
|
@ -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<Self>) -> 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<Self>,
|
||||
) -> 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<Self>) -> JobResult {
|
||||
Ok(Some(serde_json::to_value(&state.init)?))
|
||||
}
|
||||
}
|
|
@ -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<Self>) -> Result<(), JobError> {
|
||||
|
|
|
@ -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<Self>) -> 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<Self>,
|
||||
) -> 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<Self>) -> 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");
|
||||
|
|
|
@ -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<String, JobError> {
|
||||
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<FsInfo, JobError> {
|
||||
) -> Result<PathBuf, JobError> {
|
||||
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<FsInfo, JobError> {
|
||||
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 {
|
||||
|
|
|
@ -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<RW>(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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,7 +1,17 @@
|
|||
---
|
||||
index: 10
|
||||
index: 4
|
||||
---
|
||||
|
||||
# Explorer
|
||||
|
||||
using the interface, features
|
||||
### Grid view
|
||||
|
||||
### List View
|
||||
|
||||
### Columns View
|
||||
|
||||
### Media View
|
||||
|
||||
### Timeline View
|
||||
|
||||
### Configuration
|
||||
|
|
|
@ -4,4 +4,4 @@ index: 10
|
|||
|
||||
# Extensions
|
||||
|
||||
extended functionality of Spacedrive
|
||||
Extensions are planned but nothing is yet set in stone.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
index: 3
|
||||
index: 5
|
||||
---
|
||||
|
||||
# Spaces
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
index: 10
|
||||
---
|
||||
|
||||
# Rust
|
||||
|
||||
This doc should just show how to import the core and use it in a Rust context.
|
|
@ -7,3 +7,5 @@ index: 0
|
|||
```rust
|
||||
pub struct DeveloperDocumentation;
|
||||
```
|
||||
|
||||
This documentation is a work in progress, you will find unfinished or empty sections.
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -33,9 +33,12 @@ export type Procedures = {
|
|||
{ key: "tags.list", input: LibraryArgs<null>, result: Array<Tag> } |
|
||||
{ key: "volumes.list", input: never, result: Array<Volume> },
|
||||
mutations:
|
||||
{ key: "files.copyFiles", input: LibraryArgs<FileCopierJobInit>, result: null } |
|
||||
{ key: "files.cutFiles", input: LibraryArgs<FileCutterJobInit>, result: null } |
|
||||
{ key: "files.decryptFiles", input: LibraryArgs<FileDecryptorJobInit>, result: null } |
|
||||
{ key: "files.delete", input: LibraryArgs<number>, result: null } |
|
||||
{ key: "files.deleteFiles", input: LibraryArgs<FileDeleterJobInit>, result: null } |
|
||||
{ key: "files.duplicateFiles", input: LibraryArgs<FileCopierJobInit>, result: null } |
|
||||
{ key: "files.encryptFiles", input: LibraryArgs<FileEncryptorJobInit>, result: null } |
|
||||
{ key: "files.eraseFiles", input: LibraryArgs<FileEraserJobInit>, result: null } |
|
||||
{ key: "files.setFavorite", input: LibraryArgs<SetFavoriteArgs>, result: null } |
|
||||
|
@ -96,6 +99,10 @@ export interface ExplorerData { context: ExplorerContext, items: Array<ExplorerI
|
|||
|
||||
export type ExplorerItem = { type: "Path" } & FilePathWithObject | { type: "Object" } & ObjectWithFilePaths
|
||||
|
||||
export interface FileCopierJobInit { source_location_id: number, source_path_id: number, target_location_id: number, target_path: string, target_file_name_suffix: string | null }
|
||||
|
||||
export interface FileCutterJobInit { source_location_id: number, source_path_id: number, target_location_id: number, target_path: string }
|
||||
|
||||
export interface FileDecryptorJobInit { location_id: number, path_id: number, output_path: string | null, password: string | null, save_to_library: boolean | null }
|
||||
|
||||
export interface FileDeleterJobInit { location_id: number, path_id: number }
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
declare global {
|
||||
// eslint-disable-next-line
|
||||
var isDev: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
'react-native/react-native': true
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
},
|
||||
ecmaVersion: 12,
|
||||
sourceType: 'module'
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@typescript-eslint/recommended'
|
||||
],
|
||||
plugins: ['react', 'react-native'],
|
||||
rules: {
|
||||
'react/display-name': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'no-control-regex': 'off',
|
||||
'no-mixed-spaces-and-tabs': ['warn', 'smart-tabs'],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react-native',
|
||||
importNames: ['SafeAreaView'],
|
||||
message: 'Import SafeAreaView from react-native-safe-area-context instead'
|
||||
},
|
||||
{
|
||||
name: 'react-native',
|
||||
importNames: ['Button'],
|
||||
message: 'Import Button from ~/components instead.'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
ignorePatterns: ['**/*.js', '**/*.json', 'node_modules', 'android', 'ios', '.expo'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,8 +1,4 @@
|
|||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
node: true
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
|
@ -16,7 +12,8 @@ module.exports = {
|
|||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'prettier'
|
||||
'prettier',
|
||||
'turbo'
|
||||
],
|
||||
plugins: ['react'],
|
||||
rules: {
|
||||
|
@ -24,7 +21,7 @@ module.exports = {
|
|||
'react/prop-types': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/rules-of-hooks': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
|
@ -32,10 +29,12 @@ module.exports = {
|
|||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-empty-interface': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'no-control-regex': 'off',
|
||||
'no-mixed-spaces-and-tabs': ['warn', 'smart-tabs']
|
||||
},
|
||||
ignorePatterns: ['**/*.js', '**/*.json', 'node_modules'],
|
||||
ignorePatterns: ['dist', '**/*.js', '**/*.json', 'node_modules'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
27
packages/config/eslint/reactNative.js
Normal file
27
packages/config/eslint/reactNative.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
module.exports = {
|
||||
extends: [require.resolve('./base.js')],
|
||||
env: {
|
||||
'react-native/react-native': true
|
||||
},
|
||||
plugins: ['react-native'],
|
||||
ignorePatterns: ['android', 'ios', '.expo'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react-native',
|
||||
importNames: ['SafeAreaView'],
|
||||
message: 'Import SafeAreaView from react-native-safe-area-context instead'
|
||||
}
|
||||
// {
|
||||
// name: 'react-native',
|
||||
// importNames: ['Button'],
|
||||
// message: 'Import Button from ~/components instead.'
|
||||
// }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
8
packages/config/eslint/web.js
Normal file
8
packages/config/eslint/web.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
extends: [require.resolve('./base.js')],
|
||||
ignorePatterns: ['public', 'vite.config.ts'],
|
||||
env: {
|
||||
browser: true,
|
||||
node: true
|
||||
}
|
||||
};
|
3
packages/config/index.js
Normal file
3
packages/config/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
module.exports = {
|
||||
vite: require('./vite')
|
||||
};
|
|
@ -3,16 +3,18 @@
|
|||
"version": "0.0.0",
|
||||
"license": "GPL-3.0-only",
|
||||
"exports": {
|
||||
"./*": "./*",
|
||||
"./vite": "./vite/index.js"
|
||||
},
|
||||
"files": [
|
||||
"eslint-react.js"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"icons": "./scripts/generateSvgImports.mjs",
|
||||
"lint": "eslint src/**/*.{ts,tsx} && tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -b",
|
||||
"build": "tsc"
|
||||
},
|
||||
|
@ -34,7 +34,7 @@
|
|||
"@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",
|
||||
"@vitejs/plugin-react": "^2.1.0",
|
||||
"@zxcvbn-ts/core": "^2.1.0",
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Input, useZodForm, z } from '@sd/ui/src/forms';
|
|||
|
||||
const schema = z.object({ path: z.string() });
|
||||
|
||||
interface Props extends UseDialogProps {}
|
||||
type Props = UseDialogProps;
|
||||
|
||||
export default function AddLocationDialog(props: Props) {
|
||||
const dialog = useDialog(props);
|
||||
|
|
|
@ -14,7 +14,7 @@ const schema = z.object({
|
|||
filePath: z.string()
|
||||
});
|
||||
|
||||
export interface BackupRestorationDialogProps extends UseDialogProps {}
|
||||
export type BackupRestorationDialogProps = UseDialogProps;
|
||||
|
||||
export const BackupRestoreDialog = (props: BackupRestorationDialogProps) => {
|
||||
const platform = usePlatform();
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 (
|
||||
<>
|
||||
<div className="relative">
|
||||
<ExplorerContextMenu>
|
||||
<div className="relative flex flex-col w-full">
|
||||
<TopBar showSeparator={separateTopBar} />
|
||||
const onScroll = useCallback((y: number) => {
|
||||
setScrollSegments((old) => {
|
||||
return {
|
||||
...old,
|
||||
mainList: y
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<ExplorerContextMenu>
|
||||
<div className="relative flex flex-col w-full">
|
||||
<TopBar showSeparator={separateTopBar} />
|
||||
|
||||
<div className="relative flex flex-row w-full max-h-full app-background">
|
||||
{props.data && (
|
||||
<VirtualizedList
|
||||
data={props.data.items}
|
||||
context={props.data.context}
|
||||
onScroll={onScroll}
|
||||
/>
|
||||
)}
|
||||
{expStore.showInspector && (
|
||||
<div className="flex min-w-[260px] max-w-[260px]">
|
||||
<Inspector
|
||||
onScroll={(e) => {
|
||||
const y = (e.target as HTMLElement).scrollTop;
|
||||
|
||||
<div className="relative flex flex-row w-full max-h-full app-background">
|
||||
{props.data && (
|
||||
<VirtualizedList
|
||||
data={props.data.items || []}
|
||||
context={props.data.context}
|
||||
onScroll={(y) => {
|
||||
setScrollSegments((old) => {
|
||||
return {
|
||||
...old,
|
||||
mainList: y
|
||||
inspector: y
|
||||
};
|
||||
});
|
||||
}}
|
||||
key={props.data?.items[expStore.selectedRowIndex]?.id}
|
||||
data={props.data?.items[expStore.selectedRowIndex]}
|
||||
/>
|
||||
)}
|
||||
{expStore.showInspector && (
|
||||
<div className="flex min-w-[260px] max-w-[260px]">
|
||||
<Inspector
|
||||
onScroll={(e) => {
|
||||
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]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ExplorerContextMenu>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</ExplorerContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<div className="relative">
|
||||
|
@ -131,6 +139,45 @@ export function ExplorerContextMenu(props: PropsWithChildren) {
|
|||
icon={Repeat}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
label="Paste"
|
||||
keybind="⌘V"
|
||||
hidden={!store.cutCopyState.active}
|
||||
onClick={(e) => {
|
||||
if (store.cutCopyState.actionType == CutCopyType.Copy) {
|
||||
store.locationId &&
|
||||
copyFiles.mutate({
|
||||
source_location_id: store.cutCopyState.sourceLocationId,
|
||||
source_path_id: store.cutCopyState.sourcePathId,
|
||||
target_location_id: store.locationId,
|
||||
target_path: params.path,
|
||||
target_file_name_suffix: null
|
||||
});
|
||||
} else {
|
||||
store.locationId &&
|
||||
cutFiles.mutate({
|
||||
source_location_id: store.cutCopyState.sourceLocationId,
|
||||
source_path_id: store.cutCopyState.sourcePathId,
|
||||
target_location_id: store.locationId,
|
||||
target_path: params.path
|
||||
});
|
||||
}
|
||||
}}
|
||||
icon={Clipboard}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
label="Deselect"
|
||||
hidden={!store.cutCopyState.active}
|
||||
onClick={(e) => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
...store.cutCopyState,
|
||||
active: false
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
/>
|
||||
|
||||
<CM.SubMenu label="More actions..." icon={Plus}>
|
||||
<CM.Item
|
||||
onClick={() =>
|
||||
|
@ -160,6 +207,8 @@ export interface FileItemContextMenuProps extends PropsWithChildren {
|
|||
}
|
||||
|
||||
export function FileItemContextMenu({ ...props }: FileItemContextMenuProps) {
|
||||
const store = useExplorerStore();
|
||||
const params = useExplorerParams();
|
||||
const objectData = props.item ? (isObject(props.item) ? props.item : props.item.object) : null;
|
||||
|
||||
const hasMasterPasswordQuery = useLibraryQuery(['keys.hasMasterPassword']);
|
||||
|
@ -172,6 +221,8 @@ export function FileItemContextMenu({ ...props }: FileItemContextMenuProps) {
|
|||
const hasMountedKeys =
|
||||
mountedUuids.data !== undefined && mountedUuids.data.length > 0 ? true : false;
|
||||
|
||||
const copyFiles = useLibraryMutation('files.copyFiles');
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CM.ContextMenu trigger={props.children}>
|
||||
|
@ -186,7 +237,59 @@ export function FileItemContextMenu({ ...props }: FileItemContextMenuProps) {
|
|||
<CM.Separator />
|
||||
|
||||
<CM.Item label="Rename" />
|
||||
<CM.Item label="Duplicate" keybind="⌘D" />
|
||||
<CM.Item
|
||||
label="Duplicate"
|
||||
keybind="⌘D"
|
||||
onClick={(e) => {
|
||||
copyFiles.mutate({
|
||||
source_location_id: store.locationId!,
|
||||
source_path_id: props.item.id,
|
||||
target_location_id: store.locationId!,
|
||||
target_path: params.path,
|
||||
target_file_name_suffix: ' - Clone'
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
label="Cut"
|
||||
keybind="⌘X"
|
||||
onClick={(e) => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
sourceLocationId: store.locationId!,
|
||||
sourcePathId: props.item.id,
|
||||
actionType: CutCopyType.Cut,
|
||||
active: true
|
||||
};
|
||||
}}
|
||||
icon={Scissors}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
label="Copy"
|
||||
keybind="⌘C"
|
||||
onClick={(e) => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
sourceLocationId: store.locationId!,
|
||||
sourcePathId: props.item.id,
|
||||
actionType: CutCopyType.Copy,
|
||||
active: true
|
||||
};
|
||||
}}
|
||||
icon={Copy}
|
||||
/>
|
||||
|
||||
<CM.Item
|
||||
label="Deselect"
|
||||
hidden={!store.cutCopyState.active}
|
||||
onClick={(e) => {
|
||||
getExplorerStore().cutCopyState = {
|
||||
...store.cutCopyState,
|
||||
active: false
|
||||
};
|
||||
}}
|
||||
icon={FileX}
|
||||
/>
|
||||
|
||||
<CM.Separator />
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useKey, useOnWindowResize } from 'rooks';
|
||||
import { ExplorerContext, ExplorerItem } from '@sd/client';
|
||||
|
@ -17,7 +17,7 @@ interface Props {
|
|||
onScroll?: (posY: number) => void;
|
||||
}
|
||||
|
||||
export const VirtualizedList: React.FC<Props> = ({ data, context, onScroll }) => {
|
||||
export const VirtualizedList = memo(({ data, context, onScroll }: Props) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
@ -56,7 +56,7 @@ export const VirtualizedList: React.FC<Props> = ({ data, context, onScroll }) =>
|
|||
el.addEventListener('scroll', onElementScroll);
|
||||
|
||||
return () => el.removeEventListener('scroll', onElementScroll);
|
||||
}, [scrollRef, onScroll]);
|
||||
}, [onScroll]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: amountOfRows,
|
||||
|
@ -169,7 +169,7 @@ export const VirtualizedList: React.FC<Props> = ({ data, context, onScroll }) =>
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
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<WrappedItemProps> = ({ 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<WrappedItemProps> = ({ item, index, isSelected, kind
|
|||
}, [isSelected, index]);
|
||||
|
||||
const ItemComponent = kind === 'list' ? FileRow : FileItem;
|
||||
|
||||
return (
|
||||
<ItemComponent
|
||||
data={item}
|
||||
|
@ -200,18 +201,4 @@ const WrappedItem: React.FC<WrappedItemProps> = ({ 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 (
|
||||
// <ItemComponent
|
||||
// data={item}
|
||||
// index={index}
|
||||
// onClick={onClick}
|
||||
// onDoubleClick={onDoubleClick}
|
||||
// selected={isSelected}
|
||||
// />
|
||||
// );
|
||||
// }, [item, index, isSelected]);
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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<string, JobNiceData> => ({
|
|||
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
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -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<string, boolean>
|
||||
newThumbnails: {} as Record<string, boolean>,
|
||||
cutCopyState: {
|
||||
sourceLocationId: 0,
|
||||
sourcePathId: 0,
|
||||
actionType: CutCopyType.Cut,
|
||||
active: false
|
||||
}
|
||||
};
|
||||
|
||||
onLibraryChange(() => getExplorerStore().reset());
|
||||
|
|
|
@ -22,7 +22,7 @@ function LibraryListItem(props: { library: LibraryConfigWrapped; current: boolea
|
|||
<p className="mt-0.5 text-xs text-ink-dull">{props.library.uuid}</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
<Button className="!p-1.5" onClick={() => {}} variant="gray">
|
||||
<Button className="!p-1.5" variant="gray">
|
||||
<Tooltip label="TODO">
|
||||
<Database className="w-4 h-4" />
|
||||
</Tooltip>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
"types": ["vite-plugin-svgr/client", "vite/client"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["dist"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../ui"
|
||||
|
|
|
@ -18,7 +18,7 @@ let package = Package(
|
|||
dependencies: [
|
||||
// Dependencies declare other packages that this package depends on.
|
||||
// .package(url: /* package url */, from: "1.0.0"),
|
||||
.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.
|
||||
|
|
7
packages/ui/.eslintrc.js
Normal file
7
packages/ui/.eslintrc.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
module.exports = {
|
||||
extends: [require.resolve('@sd/config/eslint/web.js')],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
};
|
|
@ -16,6 +16,7 @@
|
|||
"scripts": {
|
||||
"storybook": "start-storybook -p 6006",
|
||||
"storybook:build": "build-storybook",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -b",
|
||||
"build": "tsc"
|
||||
},
|
||||
|
|
|
@ -75,7 +75,7 @@ function Remover({ id }: { id: number }) {
|
|||
() => () => {
|
||||
dialogManager.remove(id);
|
||||
},
|
||||
[]
|
||||
[id]
|
||||
);
|
||||
|
||||
return null;
|
||||
|
|
|
@ -39,21 +39,24 @@ type DropdownItemProps =
|
|||
VariantProps<typeof itemStyles>;
|
||||
|
||||
export const Item = ({ to, className, icon: Icon, children, ...props }: DropdownItemProps) => {
|
||||
let content = (
|
||||
const content = (
|
||||
<>
|
||||
{Icon && <Icon weight="bold" className={itemIconStyles(props)} />}
|
||||
<span className="text-left">{children}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return to ? (
|
||||
<Link {...props} to={to} className={clsx(itemStyles(props), className)}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<button {...props} className={clsx(itemStyles(props), className)}>
|
||||
{content}
|
||||
</button>
|
||||
return (
|
||||
<Menu.Item>
|
||||
{to ? (
|
||||
<Link {...props} to={to} className={clsx(itemStyles(props), className)}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<button {...props} className={clsx(itemStyles(props), className)}>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -4,7 +4,9 @@ import { forwardRef } from 'react';
|
|||
|
||||
export interface SwitchProps
|
||||
extends VariantProps<typeof switchStyles>,
|
||||
SwitchPrimitive.SwitchProps {}
|
||||
SwitchPrimitive.SwitchProps {
|
||||
thumbClassName?: string;
|
||||
}
|
||||
|
||||
const switchStyles = cva(
|
||||
[
|
||||
|
@ -46,9 +48,9 @@ const thumbStyles = cva(
|
|||
);
|
||||
|
||||
export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
|
||||
({ size, className, ...props }, ref) => (
|
||||
({ size, className, thumbClassName, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root {...props} ref={ref} className={switchStyles({ size, className })}>
|
||||
<SwitchPrimitive.Thumb className={thumbStyles({ size, className })} />
|
||||
<SwitchPrimitive.Thumb className={thumbStyles({ size, className: thumbClassName })} />
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
);
|
||||
|
|
|
@ -17,6 +17,7 @@ type TailwindFactory = {
|
|||
<T>(c: T): ClassnameFactory<T>;
|
||||
};
|
||||
|
||||
// eslint-ignore-next-line
|
||||
export const tw = new Proxy((() => {}) as unknown as TailwindFactory, {
|
||||
get: (_, property: string) => twFactory(property),
|
||||
apply: (_, __, [el]: [React.ReactElement]) => twFactory(el)
|
||||
|
|
|
@ -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==}
|
||||
|
|
Loading…
Reference in a new issue