[ENG-1508] Launch Spacedrop (#1893)

* Shit UI

* refactor a bit

* wip

* yeet

* farming the wry but it's stale

* Real-time hover event

* Hook with `Platform` + fix broken window-state plugin

* `DragAndDropDebug`

* Clippy

* revert Tauri v2 stuff

* minor

* probs not gonna work

* undo last commit

* a

* b

* c

* d

* e

* f

* g

* long shot

* 1

* no 7

* da hell

* large bruh moment

* lol

* zzzz

* SSH into CI

* yeet

* Tauri mouse position without new Wry

* go for gold

* Correctly lock `ort` version

* minor fixes

* debounce hover events

* WTF Tauri

* Replace DND hooks with goated versions

* wip frontend stuff

* fix ts

* disable library p2p stuff

* remove Spacedrop dialog + hook up backend

* `useOnDndLeave` working

* Close popover when drag outside

* Allow `openFilePickerDialog` for Spacedrop

* couple of fixes

* empty state

* smh

---------

Co-authored-by: Brendan Allan <brendonovich@outlook.com>
This commit is contained in:
Oscar Beaumont 2024-01-08 14:42:17 +08:00 committed by GitHub
parent 49bdbc4b60
commit bef1ebcade
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1385 additions and 577 deletions

388
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -90,6 +90,10 @@ if-watch = { git = "https://github.com/oscartbeaumont/if-watch.git", rev = "f732
# Beta features
rspc = { git = "https://github.com/spacedriveapp/rspc.git", rev = "f3347e2e8bfe3f37bfacc437ca329fe71cdcb048" }
# `cursor_position` method
tauri = { git = "https://github.com/spacedriveapp/tauri.git", rev = "8409af71a83d631ff9d1cd876c441a57511a1cbd" }
tao = { git = "https://github.com/spacedriveapp/tao", rev = "7880adbc090402c44fbcf006669458fa82623403" }
# Set the settings for build scripts and proc-macros.
[profile.dev.build-override]
opt-level = 3

View file

@ -0,0 +1,16 @@
[package]
name = "tauri-plugin-window-state"
publish = false
version = "0.1.0"
license.workspace = true
edition.workspace = true
repository.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = "1"
thiserror = "1"
log = "0.4"
bincode = "1.3"
bitflags = "2"

View file

@ -0,0 +1 @@
Fork of [tauri-plugin-window-state]( https://github.com/tauri-apps/plugins-workspace/blob/v1/plugins/window-state).

View file

@ -0,0 +1,28 @@
use crate::{AppHandleExt, StateFlags, WindowExt};
use tauri::{command, AppHandle, Manager, Runtime};
#[command]
pub async fn save_window_state<R: Runtime>(
app: AppHandle<R>,
flags: u32,
) -> std::result::Result<(), String> {
let flags = StateFlags::from_bits(flags)
.ok_or_else(|| format!("Invalid state flags bits: {}", flags))?;
app.save_window_state(flags).map_err(|e| e.to_string())?;
Ok(())
}
#[command]
pub async fn restore_state<R: Runtime>(
app: AppHandle<R>,
label: String,
flags: u32,
) -> std::result::Result<(), String> {
let flags = StateFlags::from_bits(flags)
.ok_or_else(|| format!("Invalid state flags bits: {}", flags))?;
app.get_window(&label)
.ok_or_else(|| format!("Couldn't find window with label: {}", label))?
.restore_state(flags)
.map_err(|e| e.to_string())?;
Ok(())
}

View file

@ -0,0 +1,380 @@
// Copyright 2021 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use bitflags::bitflags;
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder as PluginBuilder, TauriPlugin},
LogicalSize, Manager, Monitor, PhysicalPosition, PhysicalSize, RunEvent, Runtime, Window,
WindowEvent,
};
use std::{
collections::{HashMap, HashSet},
fs::{create_dir_all, File},
io::Write,
sync::{Arc, Mutex},
};
mod cmd;
pub const STATE_FILENAME: &str = ".window-state";
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Tauri(#[from] tauri::Error),
#[error(transparent)]
TauriApi(#[from] tauri::api::Error),
#[error(transparent)]
Bincode(#[from] Box<bincode::ErrorKind>),
}
pub type Result<T> = std::result::Result<T, Error>;
bitflags! {
#[derive(Clone, Copy, Debug)]
pub struct StateFlags: u32 {
const SIZE = 1 << 0;
const POSITION = 1 << 1;
const MAXIMIZED = 1 << 2;
const VISIBLE = 1 << 3;
const DECORATIONS = 1 << 4;
const FULLSCREEN = 1 << 5;
}
}
impl Default for StateFlags {
fn default() -> Self {
Self::all()
}
}
#[derive(Debug, Deserialize, Serialize, PartialEq)]
struct WindowState {
width: f64,
height: f64,
x: i32,
y: i32,
maximized: bool,
visible: bool,
decorated: bool,
fullscreen: bool,
}
impl Default for WindowState {
fn default() -> Self {
Self {
width: Default::default(),
height: Default::default(),
x: Default::default(),
y: Default::default(),
maximized: Default::default(),
visible: true,
decorated: true,
fullscreen: Default::default(),
}
}
}
struct WindowStateCache(Arc<Mutex<HashMap<String, WindowState>>>);
pub trait AppHandleExt {
/// Saves all open windows state to disk
fn save_window_state(&self, flags: StateFlags) -> Result<()>;
}
impl<R: Runtime> AppHandleExt for tauri::AppHandle<R> {
fn save_window_state(&self, flags: StateFlags) -> Result<()> {
if let Some(app_dir) = self.path_resolver().app_config_dir() {
let state_path = app_dir.join(STATE_FILENAME);
let cache = self.state::<WindowStateCache>();
let mut state = cache.0.lock().unwrap();
for (label, s) in state.iter_mut() {
if let Some(window) = self.get_window(label) {
window.update_state(s, flags)?;
}
}
create_dir_all(&app_dir)
.map_err(Error::Io)
.and_then(|_| File::create(state_path).map_err(Into::into))
.and_then(|mut f| {
f.write_all(&bincode::serialize(&*state).map_err(Error::Bincode)?)
.map_err(Into::into)
})
} else {
Ok(())
}
}
}
pub trait WindowExt {
/// Restores this window state from disk
fn restore_state(&self, flags: StateFlags) -> tauri::Result<()>;
}
impl<R: Runtime> WindowExt for Window<R> {
fn restore_state(&self, flags: StateFlags) -> tauri::Result<()> {
let cache = self.state::<WindowStateCache>();
let mut c = cache.0.lock().unwrap();
let mut should_show = true;
if let Some(state) = c.get(self.label()) {
// avoid restoring the default zeroed state
if *state == WindowState::default() {
return Ok(());
}
if flags.contains(StateFlags::DECORATIONS) {
self.set_decorations(state.decorated)?;
}
if flags.contains(StateFlags::SIZE) {
self.set_size(LogicalSize {
width: state.width,
height: state.height,
})?;
}
if flags.contains(StateFlags::POSITION) {
// restore position to saved value if saved monitor exists
// otherwise, let the OS decide where to place the window
for m in self.available_monitors()? {
if m.contains((state.x, state.y).into()) {
self.set_position(PhysicalPosition {
x: state.x,
y: state.y,
})?;
}
}
}
if flags.contains(StateFlags::MAXIMIZED) && state.maximized {
self.maximize()?;
}
if flags.contains(StateFlags::FULLSCREEN) {
self.set_fullscreen(state.fullscreen)?;
}
should_show = state.visible;
} else {
let mut metadata = WindowState::default();
if flags.contains(StateFlags::SIZE) {
let scale_factor = self
.current_monitor()?
.map(|m| m.scale_factor())
.unwrap_or(1.);
let size = self.inner_size()?.to_logical(scale_factor);
metadata.width = size.width;
metadata.height = size.height;
}
if flags.contains(StateFlags::POSITION) {
let pos = self.outer_position()?;
metadata.x = pos.x;
metadata.y = pos.y;
}
if flags.contains(StateFlags::MAXIMIZED) {
metadata.maximized = self.is_maximized()?;
}
if flags.contains(StateFlags::VISIBLE) {
metadata.visible = self.is_visible()?;
}
if flags.contains(StateFlags::DECORATIONS) {
metadata.decorated = self.is_decorated()?;
}
if flags.contains(StateFlags::FULLSCREEN) {
metadata.fullscreen = self.is_fullscreen()?;
}
c.insert(self.label().into(), metadata);
}
if flags.contains(StateFlags::VISIBLE) && should_show {
self.show()?;
self.set_focus()?;
}
Ok(())
}
}
trait WindowExtInternal {
fn update_state(&self, state: &mut WindowState, flags: StateFlags) -> tauri::Result<()>;
}
impl<R: Runtime> WindowExtInternal for Window<R> {
fn update_state(&self, state: &mut WindowState, flags: StateFlags) -> tauri::Result<()> {
let is_maximized = match flags.intersects(StateFlags::MAXIMIZED | StateFlags::SIZE) {
true => self.is_maximized()?,
false => false,
};
if flags.contains(StateFlags::MAXIMIZED) {
state.maximized = is_maximized;
}
if flags.contains(StateFlags::FULLSCREEN) {
state.fullscreen = self.is_fullscreen()?;
}
if flags.contains(StateFlags::DECORATIONS) {
state.decorated = self.is_decorated()?;
}
if flags.contains(StateFlags::VISIBLE) {
state.visible = self.is_visible()?;
}
if flags.contains(StateFlags::SIZE) {
let scale_factor = self
.current_monitor()?
.map(|m| m.scale_factor())
.unwrap_or(1.);
let size = self.inner_size()?.to_logical(scale_factor);
// It doesn't make sense to save a self with 0 height or width
if size.width > 0. && size.height > 0. && !is_maximized {
state.width = size.width;
state.height = size.height;
}
}
if flags.contains(StateFlags::POSITION) {
let position = self.outer_position()?;
if let Ok(Some(monitor)) = self.current_monitor() {
// save only window positions that are inside the current monitor
if monitor.contains(position) && !is_maximized {
state.x = position.x;
state.y = position.y;
}
}
}
Ok(())
}
}
#[derive(Default)]
pub struct Builder {
denylist: HashSet<String>,
skip_initial_state: HashSet<String>,
state_flags: StateFlags,
}
impl Builder {
/// Sets the state flags to control what state gets restored and saved.
pub fn with_state_flags(mut self, flags: StateFlags) -> Self {
self.state_flags = flags;
self
}
/// Sets a list of windows that shouldn't be tracked and managed by this plugin
/// for example splash screen windows.
pub fn with_denylist(mut self, denylist: &[&str]) -> Self {
self.denylist = denylist.iter().map(|l| l.to_string()).collect();
self
}
/// Adds the given window label to a list of windows to skip initial state restore.
pub fn skip_initial_state(mut self, label: &str) -> Self {
self.skip_initial_state.insert(label.into());
self
}
pub fn build<R: Runtime>(self) -> TauriPlugin<R> {
let flags = self.state_flags;
PluginBuilder::new("window-state")
.invoke_handler(tauri::generate_handler![
cmd::save_window_state,
cmd::restore_state
])
.setup(|app| {
let cache: Arc<Mutex<HashMap<String, WindowState>>> = if let Some(app_dir) =
app.path_resolver().app_config_dir()
{
let state_path = app_dir.join(STATE_FILENAME);
if state_path.exists() {
Arc::new(Mutex::new(
tauri::api::file::read_binary(state_path)
.map_err(Error::TauriApi)
.and_then(|state| bincode::deserialize(&state).map_err(Into::into))
.unwrap_or_default(),
))
} else {
Default::default()
}
} else {
Default::default()
};
app.manage(WindowStateCache(cache));
Ok(())
})
.on_webview_ready(move |window| {
if self.denylist.contains(window.label()) {
return;
}
if !self.skip_initial_state.contains(window.label()) {
let _ = window.restore_state(self.state_flags);
}
let cache = window.state::<WindowStateCache>();
let cache = cache.0.clone();
let label = window.label().to_string();
let window_clone = window.clone();
let flags = self.state_flags;
// insert a default state if this window should be tracked and
// the disk cache doesn't have a state for it
{
cache
.lock()
.unwrap()
.entry(label.clone())
.or_insert_with(WindowState::default);
}
window.on_window_event(move |e| {
if let WindowEvent::CloseRequested { .. } = e {
let mut c = cache.lock().unwrap();
if let Some(state) = c.get_mut(&label) {
let _ = window_clone.update_state(state, flags);
}
}
});
})
.on_event(move |app, event| {
if let RunEvent::Exit = event {
let _ = app.save_window_state(flags);
}
})
.build()
}
}
trait MonitorExt {
fn contains(&self, position: PhysicalPosition<i32>) -> bool;
}
impl MonitorExt for Monitor {
fn contains(&self, position: PhysicalPosition<i32>) -> bool {
let PhysicalPosition { x, y } = *self.position();
let PhysicalSize { width, height } = *self.size();
x < position.x as _
&& position.x < (x + width as i32)
&& y < position.y as _
&& position.y < (y + height as i32)
}
}

View file

@ -33,7 +33,7 @@ uuid = { workspace = true, features = ["serde"] }
thiserror.workspace = true
opener = { version = "0.6.1", features = ["reveal"] }
tauri = { version = "1.5.3", features = [
tauri = { version = "=1.5.3", features = [
"macos-private-api",
"path-all",
"protocol-all",
@ -46,8 +46,7 @@ tauri = { version = "1.5.3", features = [
"native-tls-vendored",
"tracing",
] }
tauri-plugin-window-state = "0.1.0"
tauri-plugin-window-state = { path = "../crates/tauri-plugin-window-state" }
[target.'cfg(target_os = "linux")'.dependencies]
sd-desktop-linux = { path = "../crates/linux" }

View file

@ -3,17 +3,24 @@
windows_subsystem = "windows"
)]
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
use std::{
collections::HashMap,
fs,
path::PathBuf,
sync::{Arc, Mutex, PoisonError},
time::Duration,
};
use sd_core::{Node, NodeError};
use sd_fda::DiskAccess;
use serde::{Deserialize, Serialize};
use tauri::{
api::path, ipc::RemoteDomainAccessScope, window::PlatformWebview, AppHandle, Manager,
WindowEvent,
api::path, ipc::RemoteDomainAccessScope, window::PlatformWebview, AppHandle, FileDropEvent,
Manager, Window, WindowEvent,
};
use tauri_plugins::{sd_error_plugin, sd_server_plugin};
use tauri_specta::ts;
use tauri_specta::{collect_events, ts, Event};
use tokio::time::sleep;
use tracing::error;
@ -144,6 +151,19 @@ async fn open_logs_dir(node: tauri::State<'_, Arc<Node>>) -> Result<(), ()> {
})
}
#[derive(Debug, Clone, Serialize, Deserialize, specta::Type, tauri_specta::Event)]
#[serde(tag = "type")]
pub enum DragAndDropEvent {
Hovered { paths: Vec<String>, x: f64, y: f64 },
Dropped { paths: Vec<String>, x: f64, y: f64 },
Cancelled,
}
#[derive(Default)]
pub struct DragAndDropState {
windows: HashMap<tauri::Window, tokio::task::JoinHandle<()>>,
}
const CLIENT_ID: &str = "2abb241e-40b8-4517-a3e3-5594375c8fbb";
#[tokio::main]
@ -212,6 +232,7 @@ async fn main() -> tauri::Result<()> {
let specta_builder = {
let specta_builder = ts::builder()
.events(collect_events![DragAndDropEvent])
.commands(tauri_specta::collect_commands![
app_ready,
reset_spacedrive,
@ -232,7 +253,6 @@ async fn main() -> tauri::Result<()> {
updater::check_for_update,
updater::install_update
])
// .events(tauri_specta::collect_events![])
.config(specta::ts::ExportConfig::default().formatter(specta::ts::formatter::prettier));
#[cfg(debug_assertions)]
@ -241,6 +261,7 @@ async fn main() -> tauri::Result<()> {
specta_builder.into_plugin()
};
let file_drop_status = Arc::new(Mutex::new(DragAndDropState::default()));
let app = app
.plugin(updater::plugin())
.plugin(tauri_plugin_window_state::Builder::default().build())
@ -256,8 +277,8 @@ async fn main() -> tauri::Result<()> {
if !window.is_visible().unwrap_or(true) {
// This happens if the JS bundle crashes and hence doesn't send ready event.
println!(
"Window did not emit `app_ready` event fast enough. Showing window..."
);
"Window did not emit `app_ready` event fast enough. Showing window..."
);
window.show().expect("Main window should show");
}
}
@ -297,8 +318,83 @@ async fn main() -> tauri::Result<()> {
Ok(())
})
.on_menu_event(menu::handle_menu_event)
.on_window_event(|event| {
if let WindowEvent::Resized(_) = event.event() {
.on_window_event(move |event| match event.event() {
WindowEvent::FileDrop(drop) => {
let window = event.window();
let mut file_drop_status = file_drop_status
.lock()
.unwrap_or_else(PoisonError::into_inner);
match drop {
FileDropEvent::Hovered(paths) => {
// Look this shouldn't happen but let's be sure we don't leak threads.
if file_drop_status.windows.contains_key(window) {
return;
}
// We setup a thread to keep emitting the updated position of the cursor
// It will be killed when the `FileDropEvent` is finished or cancelled.
let paths = paths.clone();
file_drop_status.windows.insert(window.clone(), {
let window = window.clone();
tokio::spawn(async move {
let (mut last_x, mut last_y) = (0.0, 0.0);
loop {
let (x, y) = mouse_position(&window);
let x_diff = difference(x, last_x);
let y_diff = difference(y, last_y);
// If the mouse hasn't moved much we will "debounce" the event
if x_diff > 28.0 || y_diff > 28.0 {
last_x = x;
last_y = y;
DragAndDropEvent::Hovered {
paths: paths
.iter()
.filter_map(|x| x.to_str().map(|x| x.to_string()))
.collect(),
x,
y,
}
.emit(&window)
.ok();
}
sleep(Duration::from_millis(125)).await;
}
})
});
}
FileDropEvent::Dropped(paths) => {
if let Some(handle) = file_drop_status.windows.remove(window) {
handle.abort();
}
let (x, y) = mouse_position(window);
DragAndDropEvent::Dropped {
paths: paths
.iter()
.filter_map(|x| x.to_str().map(|x| x.to_string()))
.collect(),
x,
y,
}
.emit(window)
.ok();
}
FileDropEvent::Cancelled => {
if let Some(handle) = file_drop_status.windows.remove(window) {
handle.abort();
}
DragAndDropEvent::Cancelled.emit(window).ok();
}
_ => unreachable!(),
}
}
WindowEvent::Resized(_) => {
let (_state, command) = if event
.window()
.is_fullscreen()
@ -320,6 +416,7 @@ async fn main() -> tauri::Result<()> {
unsafe { sd_desktop_macos::set_titlebar_style(&nswindow, _state) };
}
}
_ => {}
})
.menu(menu::get_menu())
.manage(updater::State::default())
@ -328,3 +425,28 @@ async fn main() -> tauri::Result<()> {
app.run(|_, _| {});
Ok(())
}
// Get the mouse position relative to the window
fn mouse_position(window: &Window) -> (f64, f64) {
// We apply the OS scaling factor.
// Tauri/Webkit *should* be responsible for this but it would seem it is bugged on the current webkit/tauri/wry/tao version.
// Using newer Webkit did fix this automatically but I can't for the life of me work out how to get the right glibc versions in CI so we can't ship it.
let scale_factor = window.scale_factor().unwrap();
let window_pos = window.outer_position().unwrap();
let cursor_pos = window.cursor_position().unwrap();
(
(cursor_pos.x - window_pos.x as f64) / scale_factor,
(cursor_pos.y - window_pos.y as f64) / scale_factor,
)
}
// The distance between two numbers as a positive integer.
fn difference(a: f64, b: f64) -> f64 {
let x = a - b;
if x < 0.0 {
return x * -1.0;
}
return x;
}

View file

@ -15,11 +15,10 @@ import {
TabsContext
} from '@sd/interface';
import { RouteTitleContext } from '@sd/interface/hooks/useRouteTitle';
import { getSpacedropState } from '@sd/interface/hooks/useSpacedropState';
import '@sd/ui/style/style.scss';
import { commands } from './commands';
import { commands, events } from './commands';
import { platform } from './platform';
import { queryClient } from './query';
import { createMemoryRouterWithHistory } from './router';
@ -47,15 +46,8 @@ export default function App() {
document.dispatchEvent(new KeybindEvent(input.payload as string));
});
const dropEventListener = appWindow.onFileDropEvent((event) => {
if (event.payload.type === 'drop') {
getSpacedropState().droppedFiles = event.payload.paths;
}
});
return () => {
keybindListener.then((unlisten) => unlisten());
dropEventListener.then((unlisten) => unlisten());
};
}, []);

View file

@ -178,9 +178,19 @@ export const commands = {
}
};
export const events = __makeEvents__<{
dragAndDropEvent: DragAndDropEvent;
}>({
dragAndDropEvent: 'plugin:tauri-specta:drag-and-drop-event'
});
/** user-defined types **/
export type AppThemeType = 'Auto' | 'Light' | 'Dark';
export type DragAndDropEvent =
| { type: 'Hovered'; paths: string[]; x: number; y: number }
| { type: 'Dropped'; paths: string[]; x: number; y: number }
| { type: 'Cancelled' };
export type RevealItem =
| { Location: { id: number } }
| { FilePath: { id: number } }

View file

@ -4,7 +4,7 @@ import { homeDir } from '@tauri-apps/api/path';
import { open } from '@tauri-apps/api/shell';
import { OperatingSystem, Platform } from '@sd/interface';
import { commands } from './commands';
import { commands, events } from './commands';
import { env } from './env';
import { createUpdater } from './updater';
@ -62,6 +62,10 @@ export const platform = {
saveFilePickerDialog: (opts) => dialog.save(opts),
showDevtools: () => invoke('show_devtools'),
confirm: (msg, cb) => confirm(msg).then(cb),
subscribeToDragAndDropEvents: (cb) =>
events.dragAndDropEvent.listen((e) => {
cb(e.payload);
}),
userHomeDir: homeDir,
updater: window.__SD_UPDATER__ ? createUpdater() : undefined,
auth: {

View file

@ -14,7 +14,7 @@ const DebugScreen = ({ navigation }: SettingsStackScreenProps<'Debug'>) => {
<View style={tw`flex-1 p-4`}>
<Card style={tw`gap-y-4 bg-app-box`}>
<Text style={tw`font-semibold text-ink`}>Debug</Text>
<Button onPress={() => toggleFeatureFlag(['p2pPairing', 'spacedrop'])}>
<Button onPress={() => toggleFeatureFlag(['p2pPairing'])}>
<Text style={tw`text-ink`}>Toggle P2P</Text>
</Button>
<Button onPress={() => (getDebugState().rspcLogger = !getDebugState().rspcLogger)}>

View file

@ -365,7 +365,7 @@ impl StatefulJob for MediaProcessorJobInit {
errors.push(e.to_string());
} else if has_new_labels {
// invalidate_query!(&ctx.library, "labels.count");
// invalidate_query!(&ctx.library, "labels.count"); // TODO: This query doesn't exist on main yet
}
}

View file

@ -155,7 +155,7 @@ pub async fn shallow(
"Failed to generate labels <file_path_id='{file_path_id}'>: {e:#?}"
);
} else if has_new_labels {
// invalidate_query!(library, "labels.count");
// invalidate_query!(library, "labels.count"); // TODO: This query doesn't exist on main yet
}
},
)

View file

@ -1,3 +1,5 @@
#![allow(unused)] // TODO: Remove this
use crate::library::{Libraries, Library, LibraryManagerEvent};
use sd_p2p::Service;
@ -42,35 +44,36 @@ impl LibraryServices {
}
}
pub(crate) async fn start(manager: Arc<P2PManager>, libraries: Arc<Libraries>) {
if let Err(err) = libraries
.rx
.clone()
.subscribe(|msg| {
let manager = manager.clone();
async move {
match msg {
LibraryManagerEvent::InstancesModified(library)
| LibraryManagerEvent::Load(library) => {
manager
.clone()
.libraries
.load_library(manager, &library)
.await
}
LibraryManagerEvent::Edit(library) => {
manager.libraries.edit_library(&library).await
}
LibraryManagerEvent::Delete(library) => {
manager.libraries.delete_library(&library).await
}
}
}
})
.await
{
error!("Core may become unstable! `LibraryServices::start` manager aborted with error: {err:?}");
}
pub(crate) async fn start(_manager: Arc<P2PManager>, _libraries: Arc<Libraries>) {
warn!("P2PManager has library communication disabled.");
// if let Err(err) = libraries
// .rx
// .clone()
// .subscribe(|msg| {
// let manager = manager.clone();
// async move {
// match msg {
// LibraryManagerEvent::InstancesModified(library)
// | LibraryManagerEvent::Load(library) => {
// manager
// .clone()
// .libraries
// .load_library(manager, &library)
// .await
// }
// LibraryManagerEvent::Edit(library) => {
// manager.libraries.edit_library(&library).await
// }
// LibraryManagerEvent::Delete(library) => {
// manager.libraries.delete_library(&library).await
// }
// }
// }
// })
// .await
// {
// error!("Core may become unstable! `LibraryServices::start` manager aborted with error: {err:?}");
// }
}
pub fn get(&self, id: &Uuid) -> Option<Arc<Service<LibraryMetadata>>> {

View file

@ -23,4 +23,4 @@ pub use pairing::*;
pub use peer_metadata::*;
pub use protocol::*;
pub(super) const SPACEDRIVE_APP_ID: &str = "spacedrive";
pub(super) const SPACEDRIVE_APP_ID: &str = "sd";

View file

@ -39,14 +39,14 @@ half = { version = "2.1", features = ['num-traits'] }
# "gpu" means CUDA or TensorRT EP. Thus, the ort crate cannot download them at build time.
# Ref: https://github.com/pykeio/ort/blob/d7defd1862969b4b44f7f3f4b9c72263690bd67b/build.rs#L148
[target.'cfg(target_os = "windows")'.dependencies]
ort = { version = "2.0.0-alpha.2", default-features = false, features = [
ort = { version = "=2.0.0-alpha.2", default-features = false, features = [
"ndarray",
"half",
"load-dynamic",
"directml",
] }
[target.'cfg(target_os = "linux")'.dependencies]
ort = { version = "2.0.0-alpha.2", default-features = false, features = [
ort = { version = "=2.0.0-alpha.2", default-features = false, features = [
"ndarray",
"half",
"load-dynamic",
@ -63,7 +63,7 @@ ort = { version = "2.0.0-alpha.2", default-features = false, features = [
# "armnn",
# ] }
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
ort = { version = "2.0.0-alpha.2", features = [
ort = { version = "=2.0.0-alpha.2", features = [
"ndarray",
"half",
"load-dynamic",

View file

@ -2,6 +2,7 @@ import {
Icon,
Key,
MonitorPlay,
Planet,
Rows,
SidebarSimple,
SlidersHorizontal,
@ -16,6 +17,7 @@ import { toast } from '@sd/ui';
import { useKeyMatcher } from '~/hooks';
import { KeyManager } from '../KeyManager';
import { Spacedrop, SpacedropButton } from '../Spacedrop';
import TopBarOptions, { ToolOption, TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
import { useExplorerContext } from './Context';
import OptionsPanel from './OptionsPanel';
@ -108,6 +110,13 @@ export const useExplorerTopBarOptions = () => {
});
const toolOptions = [
{
toolTipLabel: 'Spacedrop',
icon: ({ triggerOpen }) => <SpacedropButton triggerOpen={triggerOpen} />,
popOverComponent: ({ triggerClose }) => <Spacedrop triggerClose={triggerClose} />,
individual: true,
showAtResolution: 'xl:flex'
},
{
toolTipLabel: 'Key Manager',
icon: <Key className={TOP_BAR_ICON_STYLE} />,
@ -127,7 +136,7 @@ export const useExplorerTopBarOptions = () => {
individual: true,
showAtResolution: 'xl:flex'
}
].filter(Boolean) as ToolOption[];
] satisfies ToolOption[];
return {
viewOptions,

View file

@ -22,6 +22,7 @@ import {
Switch,
usePopover
} from '@sd/ui';
import { toggleRenderRects } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import Setting from '../../settings/Setting';
@ -149,6 +150,9 @@ export default () => {
<Button size="sm" variant="gray" onClick={() => navigate('./debug/cache')}>
Cache Debug
</Button>
<Button size="sm" variant="gray" onClick={() => toggleRenderRects()}>
Toggle DND Rects
</Button>
{/* {platform.showDevtools && (
<SettingContainer

View file

@ -0,0 +1,165 @@
import { Planet } from '@phosphor-icons/react';
import clsx from 'clsx';
import { useEffect, useRef, useState } from 'react';
import { proxy } from 'valtio';
import { useBridgeMutation, useDiscoveredPeers, useP2PEvents, useSelector } from '@sd/client';
import { toast } from '@sd/ui';
import { Icon } from '~/components';
import { useDropzone, useOnDndLeave } from '~/hooks';
import { usePlatform } from '~/util/Platform';
import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
import { useIncomingSpacedropToast, useSpacedropProgressToast } from './toast';
// TODO: This is super hacky so should probs be rewritten but for now it works.
const hackyState = proxy({
triggeredByDnd: false,
openPanels: 0
});
export function SpacedropProvider() {
const incomingRequestToast = useIncomingSpacedropToast();
const progressToast = useSpacedropProgressToast();
useP2PEvents((data) => {
if (data.type === 'SpacedropRequest') {
incomingRequestToast(data);
} else if (data.type === 'SpacedropProgress') {
progressToast(data);
} else if (data.type === 'SpacedropRejected') {
// TODO: Add more information to this like peer name, etc in future
toast.warning('Spacedrop Rejected');
}
});
return null;
}
export function SpacedropButton({ triggerOpen }: { triggerOpen: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const dndState = useDropzone({
ref,
onHover: () => {
hackyState.triggeredByDnd = true;
triggerOpen();
},
extendBoundsBy: 10
});
const isPanelOpen = useSelector(hackyState, (s) => s.openPanels > 0);
return (
<div ref={ref} className={dndState === 'active' && !isPanelOpen ? 'animate-bounce' : ''}>
<Planet className={TOP_BAR_ICON_STYLE} />
</div>
);
}
export function Spacedrop({ triggerClose }: { triggerClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const discoveredPeers = useDiscoveredPeers();
const doSpacedrop = useBridgeMutation('p2p.spacedrop');
// We keep track of how many instances of this component is rendering.
// This is used by `SpacedropButton` to determine if the animation should stop.
useEffect(() => {
hackyState.openPanels += 1;
return () => {
hackyState.openPanels -= 1;
};
});
// This is intentionally not reactive.
// We only want the value at the time of the initial render.
// Then we reset it to false.
const [wasTriggeredByDnd] = useState(() => hackyState.triggeredByDnd);
useEffect(() => {
hackyState.triggeredByDnd = false;
}, []);
useOnDndLeave({
ref,
onLeave: () => {
if (wasTriggeredByDnd) triggerClose();
},
extendBoundsBy: 30
});
const onDropped = (id: string, files: string[]) => {
if (doSpacedrop.isLoading) {
toast.warning('Spacedrop already in progress');
return;
}
doSpacedrop
.mutateAsync({
identity: id,
file_path: files
})
.then(() => triggerClose());
};
return (
<div ref={ref} className="flex h-full max-w-[300px] flex-col">
<div className="flex w-full flex-col items-center p-4">
<Icon name="Spacedrop" size={56} />
<span className="text-lg font-bold">Spacedrop</span>
<div className="flex flex-col space-y-4 pt-2">
{discoveredPeers.size === 0 && (
<div className="flex flex-col text-center">
<span className="text-sm text-gray-400">
No Spacedrive nodes were
<br /> found on your network
</span>
</div>
)}
{Array.from(discoveredPeers).map(([id, meta]) => (
<Node key={id} id={id} name={meta.name} onDropped={onDropped} />
))}
</div>
</div>
</div>
);
}
function Node({
id,
name,
onDropped
}: {
id: string;
name: string;
onDropped: (id: string, files: string[]) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const platform = usePlatform();
const state = useDropzone({
ref,
onDrop: (files) => onDropped(id, files)
});
return (
<div
ref={ref}
className={clsx(
'border px-4 py-2',
state === 'hovered' ? 'border-solid' : 'border-dashed'
)}
onClick={() => {
if (!platform.openFilePickerDialog) {
toast.warning('File picker not supported on this platform');
return;
}
platform.openFilePickerDialog?.().then((file) => {
const files = Array.isArray(file) || file === null ? file : [file];
if (files === null || files.length === 0) return;
onDropped(id, files);
});
}}
>
<h1>{name}</h1>
</div>
);
}

View file

@ -0,0 +1,117 @@
import { useEffect, useRef } from 'react';
import { P2PEvent, useBridgeMutation, useSpacedropProgress } from '@sd/client';
import { Input, ProgressBar, toast, ToastId } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
const placeholder = '/Users/oscar/Desktop/demo.txt';
export function useIncomingSpacedropToast() {
const platform = usePlatform();
const acceptSpacedrop = useBridgeMutation('p2p.acceptSpacedrop');
const filePathInput = useRef<HTMLInputElement>(null);
return (data: Extract<P2PEvent, { type: 'SpacedropRequest' }>) =>
toast.info(
{
title: 'Incoming Spacedrop',
// TODO: Make this pretty
body: (
<>
<p>
File '{data.files[0]}' from '{data.peer_name}'
</p>
{/* TODO: This will be removed in the future for now it's just a hack */}
{platform.saveFilePickerDialog ? null : (
<Input
ref={filePathInput}
name="file_path"
size="sm"
placeholder={placeholder}
className="w-full"
/>
)}
{/* TODO: Button to expand the toast and show the entire PeerID for manual verification? */}
</>
)
},
{
duration: 30 * 1000,
onClose: ({ event }) => {
event !== 'on-action' && acceptSpacedrop.mutate([data.id, null]);
},
action: {
label: 'Accept',
async onClick() {
let destinationFilePath = filePathInput.current?.value ?? placeholder;
if (data.files.length != 1) {
if (platform.openDirectoryPickerDialog) {
const result = await platform.openDirectoryPickerDialog({
title: 'Save Spacedrop',
multiple: false
});
if (!result) {
return;
}
destinationFilePath = result;
}
} else {
if (platform.saveFilePickerDialog) {
const result = await platform.saveFilePickerDialog({
title: 'Save Spacedrop',
defaultPath: data.files?.[0]
});
if (!result) {
return;
}
destinationFilePath = result;
}
}
if (destinationFilePath === '') return;
await acceptSpacedrop.mutateAsync([data.id, destinationFilePath]);
}
},
cancel: 'Reject'
}
);
}
export function SpacedropProgress({ toastId, dropId }: { toastId: ToastId; dropId: string }) {
const progress = useSpacedropProgress(dropId);
useEffect(() => {
if (progress === 100) {
setTimeout(() => toast.dismiss(toastId), 750);
}
}, [progress, toastId]);
return (
<div className="pt-1">
<ProgressBar percent={progress ?? 0} />
</div>
);
}
export function useSpacedropProgressToast() {
const cancelSpacedrop = useBridgeMutation(['p2p.cancelSpacedrop']);
return (data: Extract<P2PEvent, { type: 'SpacedropProgress' }>) => {
toast.info(
(id) => ({
title: 'Spacedrop',
body: <SpacedropProgress toastId={id} dropId={data.id} />
}),
{
id: data.id,
duration: Infinity,
cancel: {
label: 'Cancel',
onClick() {
cancelSpacedrop.mutate(data.id);
}
}
}
);
};
}

View file

@ -18,7 +18,7 @@ const GroupTool = forwardRef<
checkIcon
{...props}
>
{tool.icon}
{typeof tool.icon === 'function' ? tool.icon({ triggerOpen: () => {} }) : tool.icon}
{tool.toolTipLabel}
</TopBarButton>
);
@ -29,6 +29,7 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
}
export default ({ toolOptions, className }: Props) => {
const popover = usePopover();
const toolsNotSmFlex = toolOptions?.map((group) =>
group.filter((tool) => tool.showAtResolution !== 'sm:flex')
);
@ -36,7 +37,7 @@ export default ({ toolOptions, className }: Props) => {
return (
<div className={className}>
<Popover
popover={usePopover()}
popover={popover}
trigger={
<TopBarButton>
<DotsThreeCircle className={TOP_BAR_ICON_STYLE} />
@ -50,7 +51,10 @@ export default ({ toolOptions, className }: Props) => {
{group.map((tool) => (
<React.Fragment key={tool.toolTipLabel}>
{tool.popOverComponent ? (
<ToolPopover tool={tool} />
<ToolPopover
tool={tool}
triggerClose={() => popover.setOpen(false)}
/>
) : (
<GroupTool tool={tool} />
)}
@ -69,10 +73,14 @@ export default ({ toolOptions, className }: Props) => {
);
};
function ToolPopover({ tool }: { tool: ToolOption }) {
function ToolPopover({ tool, triggerClose }: { tool: ToolOption; triggerClose: () => void }) {
return (
<Popover popover={usePopover()} trigger={<GroupTool tool={tool} />}>
<div className="min-w-[250px]">{tool.popOverComponent}</div>
<div className="min-w-[250px]">
{typeof tool.popOverComponent === 'function'
? tool.popOverComponent({ triggerClose })
: tool.popOverComponent}
</div>
</Popover>
);
}

View file

@ -1,5 +1,5 @@
import clsx from 'clsx';
import { useLayoutEffect, useState } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import { ModifierKeys, Popover, Tooltip, usePopover } from '@sd/ui';
import { useIsDark, useOperatingSystem } from '~/hooks';
@ -7,13 +7,13 @@ import TopBarButton from './TopBarButton';
import TopBarMobile from './TopBarMobile';
export interface ToolOption {
icon: JSX.Element;
icon: JSX.Element | ((props: { triggerOpen: () => void }) => JSX.Element);
onClick?: () => void;
individual?: boolean;
toolTipLabel: string;
toolTipClassName?: string;
topBarActive?: boolean;
popOverComponent?: JSX.Element;
popOverComponent?: JSX.Element | ((props: { triggerClose: () => void }) => JSX.Element);
showAtResolution: ShowAtResolution;
keybinds?: Array<String | ModifierKeys>;
}
@ -127,12 +127,20 @@ function ToolGroup({
tooltipClassName={clsx('capitalize', toolTipClassName)}
label={toolTipLabel}
>
{icon}
{typeof icon === 'function'
? icon({
triggerOpen: () => popover.setOpen(true)
})
: icon}
</Tooltip>
</TopBarButton>
}
>
<div className="block min-w-[250px] max-w-[500px]">{popOverComponent}</div>
<div className="block min-w-[250px] max-w-[500px]">
{typeof popOverComponent === 'function'
? popOverComponent({ triggerClose: () => popover.setOpen(false) })
: popOverComponent}
</div>
</Popover>
) : (
<TopBarButton
@ -145,7 +153,7 @@ function ToolGroup({
tooltipClassName={clsx('capitalize', toolTipClassName)}
label={toolTipLabel}
>
{icon}
{typeof icon === 'function' ? icon({ triggerOpen: () => {} }) : icon}
</Tooltip>
</TopBarButton>
)}

View file

@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react';
import { usePlatform } from '~/util/Platform';
export function DragAndDropDebug() {
const ref = useRef<HTMLDivElement>(null);
const platform = usePlatform();
useEffect(() => {
if (!platform.subscribeToDragAndDropEvents) return;
let finished = false;
const unsub = platform.subscribeToDragAndDropEvents((event) => {
if (finished) return;
console.log(JSON.stringify(event));
if (!ref.current) return;
if (event.type === 'Hovered') {
ref.current.classList.remove('hidden');
ref.current.style.left = `${event.x}px`;
ref.current.style.top = `${event.y}px`;
} else if (event.type === 'Dropped' || event.type === 'Cancelled') {
ref.current.classList.add('hidden');
}
});
return () => {
finished = true;
void unsub.then((unsub) => unsub());
};
}, [platform, ref]);
return <div ref={ref} className="absolute z-[500] hidden h-10 w-10 bg-red-500"></div>;
}

View file

@ -11,8 +11,9 @@ import { Dialogs, Toaster } from '@sd/ui';
import { RouterErrorBoundary } from '~/ErrorFallback';
import { useRoutingContext } from '~/RoutingContext';
import { Platform } from '..';
import { Platform, usePlatform } from '..';
import libraryRoutes from './$libraryId';
import { DragAndDropDebug } from './$libraryId/debug/dnd';
import { renderDemo } from './demo.solid';
import onboardingRoutes from './onboarding';
import { RootContext } from './RootContext';
@ -43,6 +44,7 @@ export const createRoutes = (platform: Platform, cache: NormalisedCache) =>
return (
<RootContext.Provider value={{ rawPath }}>
{useFeatureFlag('debugDragAndDrop') ? <DragAndDropDebug /> : null}
{useFeatureFlag('solidJsDemo') ? <RenderSolid /> : null}
<Outlet />
<Dialogs />

View file

@ -1,235 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import {
P2PEvent,
useBridgeMutation,
useBridgeQuery,
useDiscoveredPeers,
useP2PEvents,
useSpacedropProgress,
useZodForm
} from '@sd/client';
import {
Dialog,
dialogManager,
Input,
ProgressBar,
SelectField,
SelectOption,
toast,
ToastId,
useDialog,
UseDialogProps,
z
} from '@sd/ui';
import { usePlatform } from '~/util/Platform';
import { getSpacedropState, subscribeSpacedropState } from '../../hooks/useSpacedropState';
function SpacedropProgress({ toastId, dropId }: { toastId: ToastId; dropId: string }) {
const progress = useSpacedropProgress(dropId);
useEffect(() => {
if (progress === 100) {
setTimeout(() => toast.dismiss(toastId), 750);
}
}, [progress, toastId]);
return (
<div className="pt-1">
<ProgressBar percent={progress ?? 0} />
</div>
);
}
const placeholder = '/Users/oscar/Desktop/demo.txt';
function useIncomingSpacedropToast() {
const platform = usePlatform();
const acceptSpacedrop = useBridgeMutation('p2p.acceptSpacedrop');
const filePathInput = useRef<HTMLInputElement>(null);
return (data: Extract<P2PEvent, { type: 'SpacedropRequest' }>) =>
toast.info(
{
title: 'Incoming Spacedrop',
// TODO: Make this pretty
body: (
<>
<p>
File '{data.files[0]}' from '{data.peer_name}'
</p>
{/* TODO: This will be removed in the future for now it's just a hack */}
{platform.saveFilePickerDialog ? null : (
<Input
ref={filePathInput}
name="file_path"
size="sm"
placeholder={placeholder}
className="w-full"
/>
)}
{/* TODO: Button to expand the toast and show the entire PeerID for manual verification? */}
</>
)
},
{
duration: 30 * 1000,
onClose: ({ event }) => {
event !== 'on-action' && acceptSpacedrop.mutate([data.id, null]);
},
action: {
label: 'Accept',
async onClick() {
let destinationFilePath = filePathInput.current?.value ?? placeholder;
if (data.files.length != 1) {
if (platform.openDirectoryPickerDialog) {
const result = await platform.openDirectoryPickerDialog({
title: 'Save Spacedrop',
multiple: false
});
if (!result) {
return;
}
destinationFilePath = result;
}
} else {
if (platform.saveFilePickerDialog) {
const result = await platform.saveFilePickerDialog({
title: 'Save Spacedrop',
defaultPath: data.files?.[0]
});
if (!result) {
return;
}
destinationFilePath = result;
}
}
if (destinationFilePath === '') return;
await acceptSpacedrop.mutateAsync([data.id, destinationFilePath]);
}
},
cancel: 'Reject'
}
);
}
function useSpacedropProgressToast() {
const cancelSpacedrop = useBridgeMutation(['p2p.cancelSpacedrop']);
return (data: Extract<P2PEvent, { type: 'SpacedropProgress' }>) => {
toast.info(
(id) => ({
title: 'Spacedrop',
body: <SpacedropProgress toastId={id} dropId={data.id} />
}),
{
id: data.id,
duration: Infinity,
cancel: {
label: 'Cancel',
onClick() {
cancelSpacedrop.mutate(data.id);
}
}
}
);
};
}
export function SpacedropUI() {
const node = useBridgeQuery(['nodeState']);
const incomingRequestToast = useIncomingSpacedropToast();
const progressToast = useSpacedropProgressToast();
useP2PEvents((data) => {
if (data.type === 'SpacedropRequest') {
incomingRequestToast(data);
} else if (data.type === 'SpacedropProgress') {
progressToast(data);
} else if (data.type === 'SpacedropRejected') {
// TODO: Add more information to this like peer name, etc in future
toast.warning('Spacedrop Rejected');
}
});
useEffect(() => {
let open = false;
return subscribeSpacedropState(() => {
if (node.data?.p2p_enabled === false) {
toast.error({
title: 'Spacedrop is disabled!',
body: 'Please enable networking in settings!'
});
return;
}
if (open) return;
open = true;
dialogManager.create((dp) => <SpacedropDialog {...dp} />).then(() => (open = false));
});
});
return null;
}
function SpacedropDialog(props: UseDialogProps) {
const discoveredPeers = useDiscoveredPeers();
const discoveredPeersArray = useMemo(() => [...discoveredPeers.entries()], [discoveredPeers]);
const form = useZodForm({
mode: 'onChange',
// We aren't using this but it's required for the Dialog :(
schema: z.object({
// This field is actually required but the Zod validator is not working with select's so this is good enough for now.
targetPeer: z.string().optional()
})
});
const value = form.watch('targetPeer');
useEffect(() => {
// If peer goes offline deselect it
if (
value !== undefined &&
discoveredPeersArray.find(([peerId]) => peerId === value) === undefined
)
form.setValue('targetPeer', undefined);
const defaultValue = discoveredPeersArray[0]?.[0];
// If no peer is selected, select the first one
if (value === undefined && defaultValue) form.setValue('targetPeer', defaultValue);
}, [form, value, discoveredPeersArray]);
const doSpacedrop = useBridgeMutation('p2p.spacedrop');
return (
<Dialog
// This `key` is a hack to workaround https://linear.app/spacedriveapp/issue/ENG-1208/improve-dialogs
key={props.id}
form={form}
dialog={useDialog(props)}
title="Spacedrop a File"
loading={doSpacedrop.isLoading}
ctaLabel="Send"
closeLabel="Cancel"
onSubmit={form.handleSubmit((data) =>
doSpacedrop.mutateAsync({
file_path: getSpacedropState().droppedFiles,
identity: data.targetPeer! // `submitDisabled` ensures this
})
)}
submitDisabled={value === undefined}
>
<div className="space-y-2 py-2">
<SelectField name="targetPeer">
{discoveredPeersArray.map(([peerId, metadata], index) => (
<SelectOption key={peerId} value={peerId} default={index === 0}>
{metadata.name}
</SelectOption>
))}
</SelectField>
</div>
</Dialog>
);
}

View file

@ -3,9 +3,6 @@ import { useBridgeQuery, useFeatureFlag, useP2PEvents, withFeatureFlag } from '@
import { toast } from '@sd/ui';
import { startPairing } from './pairing';
import { SpacedropUI } from './Spacedrop';
export const SpacedropUI2 = withFeatureFlag('spacedrop', SpacedropUI);
// Entrypoint of P2P UI
export function P2P() {
@ -19,11 +16,7 @@ export function P2P() {
}
});
return (
<>
<SpacedropUI2 />
</>
);
return null;
}
export function useP2PErrorToast() {

View file

@ -382,3 +382,12 @@ body {
transition-duration: 300ms;
transition-timing-function: cubic-bezier(0.85, 0, 0.15, 1);
}
@keyframes wiggle {
0%, 100% { transform: rotate(-1deg); }
50% { transform: rotate(1deg); }
}
.wiggle {
animation: wiggle 200ms infinite;
}

View file

@ -21,7 +21,7 @@ export * from './useRedirectToNewLocation';
export * from './useRouteTitle';
export * from './useShortcut';
export * from './useShowControls';
export * from './useSpacedropState';
export * from './useDragAndDropState';
export * from './useTheme';
export * from './useWindowState';
export * from './useZodRouteParams';

View file

@ -0,0 +1,180 @@
import { RefObject, useEffect, useId, useLayoutEffect, useState } from 'react';
import { proxy, useSnapshot } from 'valtio';
import { usePlatform } from '..';
const dndState = proxy({
renderRects: false
});
export const toggleRenderRects = () => (dndState.renderRects = !dndState.renderRects);
type UseDropzoneProps = {
// A ref to used to detect when the element is being hovered.
// If the file drop's mouse position is above this ref it will return a 'hovered' state.
// If none is set the 'hovered' state will never be returned.
ref?: RefObject<HTMLDivElement>;
// Handle the final file drop event.
// If `ref === undefined` this will be called for every drop event.
// If `ref !== undefined` this will only be called if the drop event is within the bounds of the ref.
onDrop?: (paths: string[]) => void;
// Called only once per each hover event.
onHover?: () => void;
// On each position of the move
onMove?: (x: number, y: number) => void;
// Added to the bounds of the shape and if the mouse is within it's counted as hovered.
// This allows for the dropzone to be bigger than the actual element to make it easier to drop on.
extendBoundsBy?: number;
};
export function isWithinRect(x: number, y: number, rect: DOMRect) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
export function expandRect(rect: DOMRect, by: number) {
return new DOMRect(rect.left - by, rect.top - by, rect.width + by * 2, rect.height + by * 2);
}
export function useDropzone(opts?: UseDropzoneProps) {
const id = useId();
const platform = usePlatform();
const [state, setState] = useState('idle' as 'idle' | 'active' | 'hovered');
const debugRect = useSnapshot(dndState).renderRects;
useEffect(() => {
if (!platform.subscribeToDragAndDropEvents) return;
let elemBounds = opts?.ref?.current?.getBoundingClientRect();
if (elemBounds && opts?.extendBoundsBy)
elemBounds = expandRect(elemBounds, opts.extendBoundsBy);
const existingDebugRectElem = document.getElementById(id);
if (existingDebugRectElem) existingDebugRectElem.remove();
if (debugRect) {
const div = document.createElement('div');
div.id = id;
div.style.position = 'absolute';
div.style.left = `${elemBounds?.left}px`;
div.style.top = `${elemBounds?.top}px`;
div.style.width = `${elemBounds?.width}px`;
div.style.height = `${elemBounds?.height}px`;
div.style.backgroundColor = 'rgba(255, 0, 0, 0.5)';
div.style.pointerEvents = 'none';
div.style.zIndex = '999';
document.body.appendChild(div);
}
let finished = false;
const unsub = platform.subscribeToDragAndDropEvents((event) => {
if (finished) return;
if (event.type === 'Hovered') {
const isHovered = elemBounds ? isWithinRect(event.x, event.y, elemBounds) : false;
setState((state) => {
// Only call it during the state transition from 'idle' -> 'active' when no `elemBounds`
if (opts?.onHover) {
if (elemBounds) {
if ((state === 'idle' || state === 'active') && isHovered)
opts.onHover();
} else {
if (state === 'idle') opts.onHover();
}
}
return isHovered ? 'hovered' : 'active';
});
if (opts?.onMove) opts.onMove(event.x, event.y);
} else if (event.type === 'Dropped') {
setState('idle');
if (elemBounds && !isWithinRect(event.x, event.y, elemBounds)) return;
if (opts?.onDrop) opts.onDrop(event.paths);
} else if (event.type === 'Cancelled') {
setState('idle');
}
});
return () => {
finished = true;
void unsub.then((unsub) => unsub());
};
}, [platform, opts, debugRect, id]);
return state;
}
type UseOnDndEnterProps = {
// Ref to the element that is being dragged over.
ref: React.RefObject<HTMLDivElement>;
// Called when the file being actively drag and dropped leaves the bounds of the ref (+ `extendBoundsBy`).
onLeave: () => void;
// Added to the bounds of the shape and if the mouse is within it's counted as hovered.
// This allows for the dropzone to be bigger than the actual element to make it easier to drop on.
extendBoundsBy?: number;
};
/// is responsible for running an action when the file being actively drag and dropped leaves the bounds of the ref.
export function useOnDndLeave({ ref, onLeave, extendBoundsBy }: UseOnDndEnterProps) {
const id = useId();
const platform = usePlatform();
const debugRect = useSnapshot(dndState).renderRects;
useLayoutEffect(() => {
if (!platform.subscribeToDragAndDropEvents) return;
let finished = false;
let mouseEnteredZone = false;
let rect: DOMRect | null = null;
// This timeout is super important. It ensures we get the ref after it's properly rendered.
// This is important if we render this component within a portal.
setTimeout(() => {
// We do this before the early return so when the element is removed the debug rect is removed.
const existingDebugRectElem = document.getElementById(id);
if (existingDebugRectElem) existingDebugRectElem.remove();
if (!ref.current) return;
rect = ref.current.getBoundingClientRect();
if (extendBoundsBy) rect = expandRect(rect, extendBoundsBy);
if (debugRect) {
const div = document.createElement('div');
div.id = id;
div.style.position = 'absolute';
div.style.left = `${rect.left}px`;
div.style.top = `${rect.top}px`;
div.style.width = `${rect.width}px`;
div.style.height = `${rect.height}px`;
div.style.backgroundColor = 'rgba(0, 255, 0, 0.5)';
div.style.pointerEvents = 'none';
div.style.zIndex = '999';
document.body.appendChild(div);
}
});
const unsub = platform.subscribeToDragAndDropEvents((event) => {
if (finished) return;
if (event.type === 'Hovered') {
if (!rect) return;
const isWithinRectNow = isWithinRect(event.x, event.y, rect);
if (mouseEnteredZone) {
if (!isWithinRectNow) onLeave();
} else {
mouseEnteredZone = isWithinRectNow;
}
} else if (event.type === 'Dropped') {
mouseEnteredZone = false;
} else if (event.type === 'Cancelled') {
mouseEnteredZone = false;
}
});
return () => {
finished = true;
void unsub.then((unsub) => unsub());
};
}, [platform, ref, onLeave, extendBoundsBy, debugRect, id]);
}

View file

@ -1,11 +0,0 @@
import { proxy, subscribe, useSnapshot } from 'valtio';
const state = proxy({
droppedFiles: [] as string[]
});
export const useSpacedropState = () => useSnapshot(state);
export const getSpacedropState = () => state;
export const subscribeSpacedropState = (callback: () => void) => subscribe(state, callback);

View file

@ -15,6 +15,7 @@ import {
import { toast, TooltipProvider } from '@sd/ui';
import { createRoutes } from './app';
import { SpacedropProvider } from './app/$libraryId/Spacedrop';
import { P2P, useP2PErrorToast } from './app/p2p';
import { Devtools } from './components/Devtools';
import { WithPrismTheme } from './components/TextViewer/prism';
@ -93,6 +94,7 @@ export function SpacedriveInterfaceRoot({ children }: PropsWithChildren) {
<P2P />
<Devtools />
<WithPrismTheme />
<SpacedropProvider />
{children}
</P2PContextProvider>
</TooltipProvider>

View file

@ -3,6 +3,13 @@ import { auth } from '@sd/client';
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
// This is copied from the Tauri Specta output.
// It will only exist on desktop
export type DragAndDropEvent =
| { type: 'Hovered'; paths: string[]; x: number; y: number }
| { type: 'Dropped'; paths: string[]; x: number; y: number }
| { type: 'Cancelled' };
// Platform represents the underlying native layer the app is running on.
// This could be Tauri or web.
export type Platform = {
@ -45,6 +52,7 @@ export type Platform = {
refreshMenuBar?(): Promise<unknown>;
setMenuBarItemState?(id: string, enabled: boolean): Promise<unknown>;
lockAppTheme?(themeType: 'Auto' | 'Light' | 'Dark'): any;
subscribeToDragAndDropEvents?(callback: (event: DragAndDropEvent) => void): Promise<() => void>;
updater?: {
useSnapshot: () => UpdateStore;
checkForUpdate(): Promise<Update | null>;

View file

@ -6,12 +6,12 @@ import { valtioPersist } from '../lib/valito';
import { nonLibraryClient, useBridgeQuery } from '../rspc';
export const features = [
'spacedrop',
'p2pPairing',
'backups',
'debugRoutes',
'solidJsDemo',
'hostedLocations'
'hostedLocations',
'debugDragAndDrop'
] as const;
// This defines which backend feature flags show up in the UI.

View file

@ -128,7 +128,7 @@ importers:
version: 2.9.0
'@tauri-apps/cli':
specifier: ^1.5.6
version: 1.5.6
version: 1.5.8
'@types/react':
specifier: ^18.2.34
version: 18.2.34
@ -10790,8 +10790,8 @@ packages:
engines: {node: '>= 14.6.0', npm: '>= 6.6.0', yarn: '>= 1.19.1'}
dev: false
/@tauri-apps/cli-darwin-arm64@1.5.6:
resolution: {integrity: sha512-NNvG3XLtciCMsBahbDNUEvq184VZmOveTGOuy0So2R33b/6FDkuWaSgWZsR1mISpOuP034htQYW0VITCLelfqg==}
/@tauri-apps/cli-darwin-arm64@1.5.8:
resolution: {integrity: sha512-/AksDWfAt3NUSt8Rq2a3gTLASChKzldPVUjmJhcbtsuzFg2nx5g+hhOHxfBYzss2Te1K5mzlu+73LAMy1Sb9Gw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@ -10799,8 +10799,8 @@ packages:
dev: true
optional: true
/@tauri-apps/cli-darwin-x64@1.5.6:
resolution: {integrity: sha512-nkiqmtUQw3N1j4WoVjv81q6zWuZFhBLya/RNGUL94oafORloOZoSY0uTZJAoeieb3Y1YK0rCHSDl02MyV2Fi4A==}
/@tauri-apps/cli-darwin-x64@1.5.8:
resolution: {integrity: sha512-gcfSh+BFRDdbIGpggZ1+5R5SgToz2A9LthH8P4ak3OHagDzDvI6ov6zy2UQE3XDWJKdnlna2rSR1dIuRZ0T9bA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@ -10808,8 +10808,8 @@ packages:
dev: true
optional: true
/@tauri-apps/cli-linux-arm-gnueabihf@1.5.6:
resolution: {integrity: sha512-z6SPx+axZexmWXTIVPNs4Tg7FtvdJl9EKxYN6JPjOmDZcqA13iyqWBQal2DA/GMZ1Xqo3vyJf6EoEaKaliymPQ==}
/@tauri-apps/cli-linux-arm-gnueabihf@1.5.8:
resolution: {integrity: sha512-ZHQYuOBGvZubPnh5n8bNaN2VMxPBZWs26960FGQWamm9569UV/TNDHb6mD0Jjk9o0f9P+f98qNhuu5Y37P+vfQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
@ -10817,8 +10817,8 @@ packages:
dev: true
optional: true
/@tauri-apps/cli-linux-arm64-gnu@1.5.6:
resolution: {integrity: sha512-QuQjMQmpsCbzBrmtQiG4uhnfAbdFx3nzm+9LtqjuZlurc12+Mj5MTgqQ3AOwQedH3f7C+KlvbqD2AdXpwTg7VA==}
/@tauri-apps/cli-linux-arm64-gnu@1.5.8:
resolution: {integrity: sha512-FFs28Ew3R2EFPYKuyAIouTbp6YnR+shAmJGFNnVy7ibKHL0wxamVKqv1N5N9gUUr+EhbZu2syMBRfG9XQ5mgng==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -10826,8 +10826,8 @@ packages:
dev: true
optional: true
/@tauri-apps/cli-linux-arm64-musl@1.5.6:
resolution: {integrity: sha512-8j5dH3odweFeom7bRGlfzDApWVOT4jIq8/214Wl+JeiNVehouIBo9lZGeghZBH3XKFRwEvU23i7sRVjuh2s8mg==}
/@tauri-apps/cli-linux-arm64-musl@1.5.8:
resolution: {integrity: sha512-dEYvNyLMmWD0jb30FNfVPXmBq6OGg6is3km+4RlGg8tZU5Zvq78ClUZtaZuER+N/hv27+Uc6UHl9X3hin8cGGw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@ -10835,8 +10835,8 @@ packages:
dev: true
optional: true
/@tauri-apps/cli-linux-x64-gnu@1.5.6:
resolution: {integrity: sha512-gbFHYHfdEGW0ffk8SigDsoXks6USpilF6wR0nqB/JbWzbzFR/sBuLVNQlJl1RKNakyJHu+lsFxGy0fcTdoX8xA==}
/@tauri-apps/cli-linux-x64-gnu@1.5.8:
resolution: {integrity: sha512-ut3TDbtLXmZhz6Q4wim57PV02wG+AfuLSWRPhTL9MsPsg/E7Y6sJhv0bIMAq6SwC59RCH52ZGft6RH7samV2NQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -10844,8 +10844,8 @@ packages:
dev: true
optional: true
/@tauri-apps/cli-linux-x64-musl@1.5.6:
resolution: {integrity: sha512-9v688ogoLkeFYQNgqiSErfhTreLUd8B3prIBSYUt+x4+5Kcw91zWvIh+VSxL1n3KCGGsM7cuXhkGPaxwlEh1ug==}
/@tauri-apps/cli-linux-x64-musl@1.5.8:
resolution: {integrity: sha512-k6ei7ETXVZlNpFOhl/8Cnj709UbEr+VuY9xKK/HgwvNfjA5f8HQ9TSKk/Um7oeT1Y61/eEcvcgF/hDURhFJDPQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@ -10853,8 +10853,8 @@ packages:
dev: true
optional: true
/@tauri-apps/cli-win32-arm64-msvc@1.5.6:
resolution: {integrity: sha512-DRNDXFNZb6y5IZrw+lhTTA9l4wbzO4TNRBAlHAiXUrH+pRFZ/ZJtv5WEuAj9ocVSahVw2NaK5Yaold4NPAxHog==}
/@tauri-apps/cli-win32-arm64-msvc@1.5.8:
resolution: {integrity: sha512-l6zm31x1inkS2K5e7otUZ90XBoK+xr2KJObFCZbzmluBE+LM0fgIXCrj7xwH/f0RCUX3VY9HHx4EIo7eLGBXKQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@ -10862,8 +10862,8 @@ packages:
dev: true
optional: true
/@tauri-apps/cli-win32-ia32-msvc@1.5.6:
resolution: {integrity: sha512-oUYKNR/IZjF4fsOzRpw0xesl2lOjhsQEyWlgbpT25T83EU113Xgck9UjtI7xemNI/OPCv1tPiaM1e7/ABdg5iA==}
/@tauri-apps/cli-win32-ia32-msvc@1.5.8:
resolution: {integrity: sha512-0k3YpWl6PKV4Qp2N52Sb45egXafSgQXcBaO7TIJG4EDfaEf5f6StN+hYSzdnrq9idrK5x9DDCPuebZTuJ+Q8EA==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
@ -10871,8 +10871,8 @@ packages:
dev: true
optional: true
/@tauri-apps/cli-win32-x64-msvc@1.5.6:
resolution: {integrity: sha512-RmEf1os9C8//uq2hbjXi7Vgz9ne7798ZxqemAZdUwo1pv3oLVZSz1/IvZmUHPdy2e6zSeySqWu1D0Y3QRNN+dg==}
/@tauri-apps/cli-win32-x64-msvc@1.5.8:
resolution: {integrity: sha512-XjBg8VMswmD9JAHKlb10NRPfBVAZoiOJBbPRte+GP1BUQtqDnbIYcOLSnUCmNZoy3fUBJuKJUBT9tDCbkMr5fQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -10880,21 +10880,21 @@ packages:
dev: true
optional: true
/@tauri-apps/cli@1.5.6:
resolution: {integrity: sha512-k4Y19oVCnt7WZb2TnDzLqfs7o98Jq0tUoVMv+JQSzuRDJqaVu2xMBZ8dYplEn+EccdR5SOMyzaLBJWu38TVK1A==}
/@tauri-apps/cli@1.5.8:
resolution: {integrity: sha512-c/mzk5vjjfxtH5uNXSc9h1eiprsolnoBcUwAa4/SZ3gxJ176CwrUKODz3cZBOnzs8omwagwgSN/j7K8NrdFL9g==}
engines: {node: '>= 10'}
hasBin: true
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 1.5.6
'@tauri-apps/cli-darwin-x64': 1.5.6
'@tauri-apps/cli-linux-arm-gnueabihf': 1.5.6
'@tauri-apps/cli-linux-arm64-gnu': 1.5.6
'@tauri-apps/cli-linux-arm64-musl': 1.5.6
'@tauri-apps/cli-linux-x64-gnu': 1.5.6
'@tauri-apps/cli-linux-x64-musl': 1.5.6
'@tauri-apps/cli-win32-arm64-msvc': 1.5.6
'@tauri-apps/cli-win32-ia32-msvc': 1.5.6
'@tauri-apps/cli-win32-x64-msvc': 1.5.6
'@tauri-apps/cli-darwin-arm64': 1.5.8
'@tauri-apps/cli-darwin-x64': 1.5.8
'@tauri-apps/cli-linux-arm-gnueabihf': 1.5.8
'@tauri-apps/cli-linux-arm64-gnu': 1.5.8
'@tauri-apps/cli-linux-arm64-musl': 1.5.8
'@tauri-apps/cli-linux-x64-gnu': 1.5.8
'@tauri-apps/cli-linux-x64-musl': 1.5.8
'@tauri-apps/cli-win32-arm64-msvc': 1.5.8
'@tauri-apps/cli-win32-ia32-msvc': 1.5.8
'@tauri-apps/cli-win32-x64-msvc': 1.5.8
dev: true
/@testing-library/dom@9.3.3: