Merge branch 'main' into closable-job-manager

This commit is contained in:
Brendan Allan 2023-01-25 12:25:05 +08:00
commit 8c4cdb1ede
82 changed files with 1240 additions and 652 deletions

View file

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

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

View file

@ -7,7 +7,7 @@ members = [
# "crates/p2p/tunnel/utils",
"apps/cli",
"apps/desktop/src-tauri",
"apps/mobile/rust",
"apps/mobile/rust/*",
"apps/server",
]

View file

@ -6,7 +6,7 @@
"repositoryURL": "https://github.com/brendonovich/swift-rs",
"state": {
"branch": null,
"revision": "833e29ba333f1dfe303eaa21de78c4f8c5a3f2ff",
"revision": "c3003bc0c28a6742d3da341b61887d8e072fda0a",
"version": null
}
}

View file

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

View file

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

View file

@ -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']
}
};

View file

@ -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": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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"

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

View 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" }

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

View 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"

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: {}",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,17 @@
---
index: 10
index: 4
---
# Explorer
using the interface, features
### Grid view
### List View
### Columns View
### Media View
### Timeline View
### Configuration

View file

@ -4,4 +4,4 @@ index: 10
# Extensions
extended functionality of Spacedrive
Extensions are planned but nothing is yet set in stone.

View file

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

View file

@ -1,5 +1,5 @@
---
index: 3
index: 5
---
# Spaces

View file

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

View file

@ -0,0 +1,7 @@
---
index: 10
---
# Rust
This doc should just show how to import the core and use it in a Rust context.

View file

@ -7,3 +7,5 @@ index: 0
```rust
pub struct DeveloperDocumentation;
```
This documentation is a work in progress, you will find unfinished or empty sections.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
declare global {
// eslint-disable-next-line
var isDev: boolean;
}

View file

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

View file

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

View 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.'
// }
]
}
]
}
};

View 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
View file

@ -0,0 +1,3 @@
module.exports = {
vite: require('./vite')
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -9,6 +9,7 @@
"types": ["vite-plugin-svgr/client", "vite/client"]
},
"include": ["src"],
"exclude": ["dist"],
"references": [
{
"path": "../ui"

View file

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

@ -0,0 +1,7 @@
module.exports = {
extends: [require.resolve('@sd/config/eslint/web.js')],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
};

View file

@ -16,6 +16,7 @@
"scripts": {
"storybook": "start-storybook -p 6006",
"storybook:build": "build-storybook",
"lint": "eslint src",
"typecheck": "tsc -b",
"build": "tsc"
},

View file

@ -75,7 +75,7 @@ function Remover({ id }: { id: number }) {
() => () => {
dialogManager.remove(id);
},
[]
[id]
);
return null;

View file

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

View file

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

View file

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

View file

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