Merge remote-tracking branch 'origin/main' into use-rust-shortcuts

This commit is contained in:
maxichrome 2022-09-15 02:15:40 -05:00
parent 0768005106
commit 4673867ad1
163 changed files with 2497 additions and 2552 deletions

View file

@ -1,19 +0,0 @@
name: Create diagram
on:
push:
branches:
- main
workflow_dispatch:
jobs:
get_data:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Update diagram
uses: githubocto/repo-visualizer@main
with:
excluded_paths: '.github'

View file

@ -1,28 +0,0 @@
name: Update Org README
on:
push:
branches:
- main
paths:
- README.md
workflow_dispatch:
jobs:
update-readme:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Update README
uses: dmnemec/copy_file_to_another_repo_action@main
env:
API_TOKEN_GITHUB: ${{ secrets.SD_BOT_PAT }}
with:
source_file: 'README.md'
destination_repo: 'spacedriveapp/.github'
destination_folder: 'profile'
user_email: 'actions@spacedrive.com'
user_name: 'GH Actions'
commit_message: 'Update README'

View file

@ -23,11 +23,13 @@
"subpackage",
"svgr",
"tailwindcss",
"tanstack",
"titlebar",
"trivago",
"tsparticles",
"unlisten",
"upsert"
"upsert",
"valtio"
],
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"

View file

@ -25,10 +25,8 @@ swift-rs = { git = "https://github.com/Brendonovich/swift-rs.git", branch = "aut
tauri-build = { version = "1.0.0", features = [] }
[target.'cfg(target_os = "macos")'.build-dependencies]
swift-rs = { git = "https://github.com/Brendonovich/swift-rs.git", branch = "autorelease", features = [
"build",
] }
swift-rs = { git = "https://github.com/Brendonovich/swift-rs.git", branch = "autorelease", features = ["build"] }
[features]
default = ["custom-protocol"]
custom-protocol = ["tauri/custom-protocol"]
default = [ "custom-protocol" ]
custom-protocol = [ "tauri/custom-protocol" ]

View file

@ -6,7 +6,12 @@
use std::path::PathBuf;
use sdcore::Node;
use tauri::{api::path, async_runtime::block_on, Manager, RunEvent};
use tauri::{
api::path,
async_runtime::block_on,
http::{ResponseBuilder, Uri},
Manager, RunEvent,
};
use tracing::{debug, error};
#[cfg(target_os = "macos")]
mod macos;
@ -32,6 +37,20 @@ async fn main() {
let node = node.clone();
move || node.get_request_context()
}))
.register_uri_scheme_protocol("spacedrive", {
let node = node.clone();
move |_, req| {
let url = req.uri().parse::<Uri>().unwrap();
let mut path = url.path().split('/').collect::<Vec<_>>();
path[0] = url.host().unwrap(); // The first forward slash causes an empty item and we replace it with the URL's host which you expect to be at the start
let (status_code, content_type, body) = node.handle_custom_uri(path);
ResponseBuilder::new()
.status(status_code)
.mimetype(content_type)
.body(body)
}
})
.setup(|app| {
let app = app.handle();

View file

@ -79,7 +79,7 @@
}
],
"security": {
"csp": "default-src asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
"csp": "default-src spacedrive: asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
}
}
}

View file

@ -78,7 +78,7 @@
}
],
"security": {
"csp": "default-src asset: blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
"csp": "default-src spacedrive: asset: https://asset.localhost blob: data: filesystem: ws: wss: http: https: tauri: 'unsafe-eval' 'unsafe-inline' 'self' img-src: 'self'"
}
}
}

View file

@ -8,6 +8,7 @@
</head>
<body style="overflow: hidden">
<div id="root"></div>
<script src="http://localhost:8097"></script>
<script type="module" src="./index.tsx"></script>
</body>
</html>

View file

@ -1,14 +1,11 @@
// import Spacedrive JS client
import { createClient } from '@rspc/client';
import { TauriTransport } from '@rspc/tauri';
import { Operations, queryClient, rspc } from '@sd/client';
import { OperatingSystem, Operations, PlatformProvider, queryClient, rspc } from '@sd/client';
import SpacedriveInterface, { Platform } from '@sd/interface';
import { KeybindEvent } from '@sd/interface';
import { dialog, invoke, os, shell } from '@tauri-apps/api';
import { dialog, invoke, os } from '@tauri-apps/api';
import { listen } from '@tauri-apps/api/event';
import { convertFileSrc } from '@tauri-apps/api/tauri';
import { appWindow } from '@tauri-apps/api/window';
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import '@sd/ui/style';
@ -17,60 +14,48 @@ const client = createClient<Operations>({
transport: new TauriTransport()
});
function App() {
function getPlatform(platform: string): Platform {
switch (platform) {
case 'darwin':
return 'macOS';
case 'win32':
return 'windows';
case 'linux':
return 'linux';
default:
return 'browser';
}
async function getOs(): Promise<OperatingSystem> {
switch (await os.type()) {
case 'Linux':
return 'linux';
case 'Windows_NT':
return 'windows';
case 'Darwin':
return 'macOS';
default:
return 'unknown';
}
}
const [platform, setPlatform] = useState<Platform>('unknown');
const [focused, setFocused] = useState(true);
const platform: Platform = {
platform: 'tauri',
getThumbnailUrlById: (casId) => `spacedrive://thumbnail/${encodeURIComponent(casId)}`,
openLink: open,
getOs,
openFilePickerDialog: () => dialog.open({ directory: true })
};
function App() {
useEffect(() => {
os.platform().then((platform) => setPlatform(getPlatform(platform)));
// This tells Tauri to show the current window because it's finished loading
invoke('app_ready');
}, []);
useEffect(() => {
const focusListener = listen('tauri://focus', () => setFocused(true));
const blurListener = listen('tauri://blur', () => setFocused(false));
const keybindListener = listen('keybind', (input) => {
document.dispatchEvent(new KeybindEvent(input.payload as string));
});
return () => {
focusListener.then((unlisten) => unlisten());
blurListener.then((unlisten) => unlisten());
keybindListener.then((unlisten) => unlisten());
};
}, []);
return (
<rspc.Provider client={client} queryClient={queryClient}>
<SpacedriveInterface
platform={platform}
convertFileSrc={function (url: string): string {
return convertFileSrc(url);
}}
openDialog={function (options: {
directory?: boolean | undefined;
}): Promise<string | string[] | null> {
return dialog.open(options);
}}
isFocused={focused}
onClose={() => appWindow.close()}
onFullscreen={() => appWindow.setFullscreen(true)}
onMinimize={() => appWindow.minimize()}
onOpen={(path: string) => shell.open(path)}
/>
<PlatformProvider platform={platform}>
<SpacedriveInterface />
</PlatformProvider>
</rspc.Provider>
);
}

View file

@ -10,9 +10,7 @@ export default defineConfig({
port: 8001
},
plugins: [
react({
jsxRuntime: 'classic'
}),
react(),
svgr({
svgrOptions: {
icon: true

View file

@ -1,6 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
import clsx from 'clsx';
import React, { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { getWindow } from '../utils';

View file

@ -1,6 +1,4 @@
import { Tag } from '@tryghost/content-api';
import clsx from 'clsx';
import React from 'react';
export interface BlogTagProps {
tag: Tag;

View file

@ -1,4 +1,3 @@
import React from 'react';
import Particles from 'react-tsparticles';
import { loadFull } from 'tsparticles';

View file

@ -7,7 +7,6 @@ import {
Twitter
} from '@icons-pack/react-simple-icons';
import AppLogo from '@sd/assets/images/logo.png';
import React from 'react';
function FooterLink(props: { children: string | JSX.Element; link: string; blank?: boolean }) {
return (

View file

@ -1,6 +1,6 @@
import Prism from 'prismjs';
import 'prismjs/components/prism-rust';
import React, { useEffect } from 'react';
import { useEffect } from 'react';
import '../atom-one.css';

View file

@ -9,7 +9,7 @@ import AppLogo from '@sd/assets/images/logo.png';
import { Dropdown, DropdownItem } from '@sd/ui';
import clsx from 'clsx';
import { List } from 'phosphor-react';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { positions } from '../pages/careers.page';
import { getWindow } from '../utils';

View file

@ -1,5 +1,3 @@
import React from 'react';
export interface NewBannerProps {
headline: string;
href: string;

View file

@ -1,6 +1,5 @@
import { Github, Twitch, Twitter } from '@icons-pack/react-simple-icons';
import clsx from 'clsx';
import React from 'react';
export interface TeamMemberProps {
// Name of team member

View file

@ -1,5 +1,4 @@
import { PostOrPage, PostsOrPages, Tag } from '@tryghost/content-api';
import React, { useEffect, useState } from 'react';
import { PostOrPage, Tag } from '@tryghost/content-api';
import { Helmet } from 'react-helmet';
import { BlogTag } from '../../components/BlogTag';

View file

@ -1,7 +1,7 @@
import { PostOrPage, Tag } from '@tryghost/content-api';
import Prism from 'prismjs';
import 'prismjs/components/prism-rust';
import React, { useEffect } from 'react';
import { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import '../../atom-one.css';

View file

@ -10,7 +10,7 @@ import {
StarIcon
} from '@heroicons/react/24/outline';
import { Button } from '@sd/ui';
import React from 'react';
import { useRef } from 'react';
import { Helmet } from 'react-helmet';
interface PositionPosting {
@ -59,7 +59,7 @@ const perks = [
},
{
title: 'Paid Time Off',
desc: `Rest is important, you deliver your best work when you've had your downtime. We offer 2 weeks paid time off per year, and if you need more, we'll give you more.`,
desc: `Rest is important, you deliver your best work when you've had your downtime. We offer 4 weeks paid time off per year, and if you need more, we'll give you more.`,
icon: FaceSmileIcon,
color: '#9210FF'
},
@ -84,7 +84,7 @@ const perks = [
];
function Page() {
const openPositionsRef = React.useRef<HTMLHRElement>(null);
const openPositionsRef = useRef<HTMLHRElement>(null);
const scrollToPositions = () => openPositionsRef.current?.scrollIntoView({ behavior: 'smooth' });
return (

View file

@ -1,4 +1,3 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { ReactComponent as Content } from '~/docs/changelog/index.md';

View file

@ -1,4 +1,3 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { ReactComponent as Content } from '~/docs/architecture/distributed-data-sync.md';

View file

@ -1,4 +1,3 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { ReactComponent as Content } from '~/docs/product/faq.md';

View file

@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { Suspense, useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { ReactComponent as Info } from '@sd/interface/assets/svg/info.svg';
@ -100,11 +100,11 @@ function Page() {
</Helmet>
<div className="mt-22 lg:mt-28" id="content" aria-hidden="true" />
<div className="mt-24 lg:mt-5" />
<NewBanner
headline="Spacedrive raises $2M led by OSS Capital"
href="/blog/spacedrive-funding-announcement"
link="Read post"
/>
<NewBanner
headline="Spacedrive raises $2M led by OSS Capital"
href="/blog/spacedrive-funding-announcement"
link="Read post"
/>
{unsubscribedFromWaitlist && (
<div
className={

View file

@ -1,4 +1,3 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { ReactComponent as Content } from '~/docs/product/roadmap.md';

View file

@ -1,4 +1,3 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { ReactComponent as ArrowRight } from '@sd/interface/assets/svg/arrow-right.svg';

View file

@ -1,20 +1,19 @@
import React from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from '../App'
import type { PageContext } from './types'
import type { PageContextBuiltInClient } from 'vite-plugin-ssr/client'
import { hydrateRoot } from 'react-dom/client';
import type { PageContextBuiltInClient } from 'vite-plugin-ssr/client';
export { render }
import App from '../App';
import type { PageContext } from './types';
export { render };
async function render(pageContext: PageContextBuiltInClient & PageContext) {
const { Page, pageProps } = pageContext
hydrateRoot(
document.getElementById('page-view')!,
<App pageContext={pageContext as any}>
<Page {...pageProps} />
</App>,
)
const { Page, pageProps } = pageContext;
hydrateRoot(
document.getElementById('page-view')!,
<App pageContext={pageContext as any}>
<Page {...pageProps} />
</App>
);
}
export const clientRouting = true
export const clientRouting = true;

View file

@ -1,4 +1,3 @@
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { Helmet } from 'react-helmet';
import { dangerouslySkipEscape, escapeInject } from 'vite-plugin-ssr';

View file

@ -1,6 +1,5 @@
import { Button } from '@sd/ui';
import { SmileyXEyes } from 'phosphor-react';
import React from 'react';
import { Helmet } from 'react-helmet';
import Markdown from '../components/Markdown';

View file

@ -1,6 +1,6 @@
// `usePageContext` allows us to access `pageContext` in any React component.
// More infos: https://vite-plugin-ssr.com/pageContext-anywhere
import React, { useContext } from 'react';
import { ReactNode, createContext, useContext } from 'react';
import { PageContextBuiltIn } from 'vite-plugin-ssr';
import type { PageContext } from './types';
@ -8,14 +8,14 @@ import type { PageContext } from './types';
export { PageContextProvider };
export { usePageContext };
const Context = React.createContext<PageContextBuiltIn>(undefined as any);
const Context = createContext<PageContextBuiltIn>(undefined as any);
function PageContextProvider({
pageContext,
children
}: {
pageContext: PageContextBuiltIn;
children: React.ReactNode;
children: ReactNode;
}) {
return <Context.Provider value={pageContext}>{children}</Context.Provider>;
}

View file

@ -19,6 +19,5 @@ declare module '*.md' {
const html: string;
// When "Mode.React" is requested. VFC could take a generic like React.VFC<{ MyComponent: TypeOfMyComponent }>
import React from 'react';
const ReactComponent: React.VFC;
}

View file

@ -2,7 +2,7 @@ import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { DefaultTheme, NavigationContainer, Theme } from '@react-navigation/native';
import { createClient } from '@rspc/client';
import { StatusBar } from 'expo-status-bar';
import React, { useEffect } from 'react';
import { useEffect } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useDeviceContext } from 'twrnc';

View file

@ -7,7 +7,7 @@ export const setItemToStorage = async (key: string, value: string | null) => {
return true;
} catch (e: any) {
// saving error
console.log('Error', e);
console.error('Error', e);
return false;
}
};
@ -20,7 +20,7 @@ export const getItemFromStorage = async (key: string) => {
}
return undefined;
} catch (e: any) {
console.log('Error', e);
console.error('Error', e);
return undefined;
}
@ -34,7 +34,7 @@ export const setObjStorage = async (key: string, value: any) => {
return true;
} catch (e: any) {
// saving error
console.log('Error', e);
console.error('Error', e);
return false;
}
@ -49,7 +49,7 @@ export const getObjFromStorage = async (key: string) => {
return null;
} catch (e: any) {
// error reading value
console.log('Error', e);
console.error('Error', e);
return null;
}
@ -60,7 +60,7 @@ export async function removeFromStorage(key: string) {
await AsyncStorage.removeItem(key);
} catch (e: any) {
// remove error
console.log('Error', e);
console.error('Error', e);
}
}

View file

@ -26,10 +26,10 @@ export type Operations = {
{ key: ["files.setNote", LibraryArgs<SetNoteArgs>], result: null } |
{ key: ["jobs.generateThumbsForLocation", LibraryArgs<GenerateThumbsForLocationArgs>], result: null } |
{ key: ["jobs.identifyUniqueFiles", LibraryArgs<IdentifyUniqueFilesArgs>], result: null } |
{ key: ["library.create", string], result: null } |
{ key: ["library.create", string], result: LibraryConfigWrapped } |
{ key: ["library.delete", string], result: null } |
{ key: ["library.edit", EditLibraryArgs], result: null } |
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: Location } |
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: null } |
{ key: ["locations.delete", LibraryArgs<number>], result: null } |
{ key: ["locations.fullRescan", LibraryArgs<number>], result: null } |
{ key: ["locations.indexer_rules.create", LibraryArgs<IndexerRuleCreateArgs>], result: IndexerRule } |
@ -69,7 +69,7 @@ export interface IndexerRuleCreateArgs { kind: RuleKind, name: string, parameter
export interface InvalidateOperationEvent { key: string, arg: any }
export interface JobReport { id: string, name: string, data: Array<number> | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: number }
export interface JobReport { id: string, name: string, data: Array<number> | null, metadata: any | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: number }
export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused"

View file

@ -1,6 +1,11 @@
use std::{env, net::SocketAddr, path::Path};
use axum::{handler::Handler, routing::get};
use axum::{
extract,
handler::Handler,
http::{header::CONTENT_TYPE, HeaderMap, StatusCode},
routing::get,
};
use sdcore::Node;
use tracing::info;
@ -34,6 +39,23 @@ async fn main() {
let app = axum::Router::new()
.route("/", get(|| async { "Spacedrive Server!" }))
.route("/health", get(|| async { "OK" }))
.route("/spacedrive/:id", {
let node = node.clone();
get(|extract::Path(path): extract::Path<String>| async move {
let (status_code, content_type, body) =
node.handle_custom_uri(path.split('/').collect());
(
StatusCode::from_u16(status_code).unwrap(),
{
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, content_type.parse().unwrap());
headers
},
body,
)
})
})
.route(
"/rspcws",
router.axum_ws_handler(move || node.get_request_context()),

View file

@ -1,6 +1,6 @@
import { WebsocketTransport, createClient } from '@rspc/client';
import { Operations, queryClient, rspc } from '@sd/client';
import SpacedriveInterface from '@sd/interface';
import { Operations, PlatformProvider, queryClient, rspc } from '@sd/client';
import SpacedriveInterface, { Platform } from '@sd/interface';
import { useEffect } from 'react';
const client = createClient<Operations>({
@ -9,26 +9,22 @@ const client = createClient<Operations>({
)
});
const platform: Platform = {
platform: 'web',
getThumbnailUrlById: (casId) => `spacedrive://thumbnail/${encodeURIComponent(casId)}`,
openLink: (url) => window.open(url, '_blank')?.focus(),
demoMode: true
};
function App() {
useEffect(() => {
window.parent.postMessage('spacedrive-hello', '*');
}, []);
useEffect(() => window.parent.postMessage('spacedrive-hello', '*'), []);
return (
<div className="App">
<rspc.Provider client={client} queryClient={queryClient}>
<SpacedriveInterface
demoMode
platform={'browser'}
convertFileSrc={function (url: string): string {
return url;
}}
openDialog={function (options: {
directory?: boolean | undefined;
}): Promise<string | string[]> {
return Promise.resolve([]);
}}
/>
<PlatformProvider platform={platform}>
<SpacedriveInterface />
</PlatformProvider>
</rspc.Provider>
</div>
);

View file

@ -10,14 +10,7 @@ export default defineConfig({
server: {
port: 8002
},
plugins: [
// @ts-ignore
react({
jsxRuntime: 'classic'
}),
svg({ svgrOptions: { icon: true } }),
tsconfigPaths()
],
plugins: [react(), svg({ svgrOptions: { icon: true } }), tsconfigPaths()],
root: 'src',
publicDir: '../../packages/interface/src/assets',
define: {

View file

@ -10,13 +10,9 @@ rust-version = "1.63.0"
[features]
default = ["p2p"]
p2p = [
] # This feature controlls whether the Spacedrive Core contains the Peer to Peer syncing engine (It isn't required for the hosted core so we can disable it).
mobile = [
] # This feature allows features to be disabled when the Core is running on mobile.
ffmpeg = [
"dep:ffmpeg-next",
] # This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg.
p2p = [] # This feature controlls whether the Spacedrive Core contains the Peer to Peer syncing engine (It isn't required for the hosted core so we can disable it).
mobile = [] # This feature allows features to be disabled when the Core is running on mobile.
ffmpeg = ["dep:ffmpeg-next"] # This feature controls whether the Spacedrive Core contains functionality which requires FFmpeg.
[dependencies]
hostname = "0.3.1"
@ -64,4 +60,4 @@ enumflags2 = "0.7.5"
[dev-dependencies]
tempfile = "^3.3.0"
tracing-test = "^0.2.3"
tracing-test = "^0.2.3"

View file

@ -26,10 +26,10 @@ export type Operations = {
{ key: ["files.setNote", LibraryArgs<SetNoteArgs>], result: null } |
{ key: ["jobs.generateThumbsForLocation", LibraryArgs<GenerateThumbsForLocationArgs>], result: null } |
{ key: ["jobs.identifyUniqueFiles", LibraryArgs<IdentifyUniqueFilesArgs>], result: null } |
{ key: ["library.create", string], result: null } |
{ key: ["library.create", string], result: LibraryConfigWrapped } |
{ key: ["library.delete", string], result: null } |
{ key: ["library.edit", EditLibraryArgs], result: null } |
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: Location } |
{ key: ["locations.create", LibraryArgs<LocationCreateArgs>], result: null } |
{ key: ["locations.delete", LibraryArgs<number>], result: null } |
{ key: ["locations.fullRescan", LibraryArgs<number>], result: null } |
{ key: ["locations.indexer_rules.create", LibraryArgs<IndexerRuleCreateArgs>], result: IndexerRule } |
@ -69,7 +69,7 @@ export interface IndexerRuleCreateArgs { kind: RuleKind, name: string, parameter
export interface InvalidateOperationEvent { key: string, arg: any }
export interface JobReport { id: string, name: string, data: Array<number> | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: number }
export interface JobReport { id: string, name: string, data: Array<number> | null, metadata: any | null, date_created: string, date_modified: string, status: JobStatus, task_count: number, completed_task_count: number, message: string, seconds_elapsed: number }
export type JobStatus = "Queued" | "Running" | "Completed" | "Canceled" | "Failed" | "Paused"

View file

@ -225,6 +225,7 @@ CREATE TABLE "jobs" (
"action" INTEGER NOT NULL,
"status" INTEGER NOT NULL DEFAULT 0,
"data" BLOB,
"metadata" BLOB,
"task_count" INTEGER NOT NULL DEFAULT 1,
"completed_task_count" INTEGER NOT NULL DEFAULT 0,
"date_created" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

View file

@ -313,12 +313,13 @@ model FileInSpace {
}
model Job {
id Bytes @id
name String
node_id Int
action Int
status Int @default(0)
data Bytes?
id Bytes @id
name String
node_id Int
action Int
status Int @default(0)
data Bytes?
metadata Bytes?
task_count Int @default(1)
completed_task_count Int @default(0)

View file

@ -2,9 +2,11 @@ use crate::{
encode::{ThumbnailJob, ThumbnailJobInit},
file::cas::{FileIdentifierJob, FileIdentifierJobInit},
job::{Job, JobManager},
location::{fetch_location, LocationError},
prisma::location,
};
use rspc::Type;
use rspc::{ErrorCode, Type};
use serde::Deserialize;
use std::path::PathBuf;
@ -33,6 +35,16 @@ pub(crate) fn mount() -> RouterBuilder {
.library_mutation(
"generateThumbsForLocation",
|_, args: GenerateThumbsForLocationArgs, library| async move {
if library
.db
.location()
.count(vec![location::id::equals(args.id)])
.exec()
.await? == 0
{
return Err(LocationError::IdNotFound(args.id).into());
}
library
.spawn_job(Job::new(
ThumbnailJobInit {
@ -50,6 +62,13 @@ pub(crate) fn mount() -> RouterBuilder {
.library_mutation(
"identifyUniqueFiles",
|_, args: IdentifyUniqueFilesArgs, library| async move {
if fetch_location(&library, args.id).exec().await?.is_none() {
return Err(rspc::Error::new(
ErrorCode::NotFound,
"Location not found".into(),
));
}
library
.spawn_job(Job::new(
FileIdentifierJobInit {

View file

@ -2,8 +2,9 @@ use crate::{
encode::THUMBNAIL_CACHE_DIR_NAME,
invalidate_query,
location::{
indexer::indexer_rules::IndexerRuleCreateArgs, scan_location, LocationCreateArgs,
LocationUpdateArgs,
fetch_location,
indexer::{indexer_job::indexer_job_location, indexer_rules::IndexerRuleCreateArgs},
scan_location, LocationCreateArgs, LocationError, LocationUpdateArgs,
},
prisma::{file, file_path, indexer_rule, indexer_rules_in_location, location, tag},
};
@ -129,9 +130,8 @@ pub(crate) fn mount() -> RouterBuilder {
"create",
|_, args: LocationCreateArgs, library| async move {
let location = args.create(&library).await?;
scan_location(&library, location.id).await?;
Ok(location)
scan_location(&library, location).await?;
Ok(())
},
)
.library_mutation(
@ -171,9 +171,16 @@ pub(crate) fn mount() -> RouterBuilder {
Ok(())
})
.library_mutation("fullRescan", |_, location_id: i32, library| async move {
scan_location(&library, location_id)
.await
.map_err(Into::into)
scan_location(
&library,
fetch_location(&library, location_id)
.include(indexer_job_location::include())
.exec()
.await?
.ok_or(LocationError::IdNotFound(location_id))?,
)
.await
.map_err(Into::into)
})
.library_mutation("quickRescan", |_, _: (), _| async move {
#[allow(unreachable_code)]

View file

@ -44,6 +44,7 @@ pub(crate) struct InvalidRequests {
}
impl InvalidRequests {
#[allow(unused)]
const fn new() -> Self {
Self {
queries: Vec::new(),

View file

@ -53,13 +53,12 @@ impl StatefulJob for ThumbnailJob {
&self,
ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> JobResult {
) -> Result<(), JobError> {
let library_ctx = ctx.library_ctx();
let thumbnail_dir = library_ctx
.config()
.data_directory()
.join(THUMBNAIL_CACHE_DIR_NAME);
// .join(state.init.location_id.to_string());
let location = library_ctx
.db
@ -102,7 +101,7 @@ impl StatefulJob for ThumbnailJob {
&self,
ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> JobResult {
) -> Result<(), JobError> {
let step = &state.steps[0];
ctx.progress(vec![JobReportUpdate::Message(format!(
"Processing {}",
@ -163,7 +162,7 @@ impl StatefulJob for ThumbnailJob {
&self,
_ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> Result<(), JobError> {
) -> JobResult {
let data = state
.data
.as_ref()
@ -173,7 +172,9 @@ impl StatefulJob for ThumbnailJob {
state.init.location_id,
data.root_path.display()
);
Ok(())
// TODO: Serialize and return metadata here
Ok(None)
}
}

View file

@ -66,15 +66,17 @@ impl StatefulJob for FileIdentifierJob {
&self,
ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> JobResult {
info!("Identifying orphan file paths...");
) -> Result<(), JobError> {
info!("Identifying orphan Paths...");
let library = ctx.library_ctx();
let location_id = state.init.location_id;
let location = library
.db
.location()
.find_unique(location::id::equals(state.init.location_id))
.find_unique(location::id::equals(location_id))
.exec()
.await?
.unwrap();
@ -89,18 +91,30 @@ impl StatefulJob for FileIdentifierJob {
info!("Found {} orphan file paths", total_count);
let task_count = (total_count as f64 / CHUNK_SIZE as f64).ceil() as usize;
info!("Will process {} tasks", task_count);
info!(
"Found {} orphan Paths. Will execute {} tasks...",
total_count, task_count
);
// update job with total task count based on orphan file_paths count
ctx.progress(vec![JobReportUpdate::TaskCount(task_count)]);
let first_path_id = library
.db
.file_path()
.find_first(orphan_path_filters(location_id, None))
.exec()
.await?
.map(|d| d.id)
.unwrap_or(1);
state.data = Some(FileIdentifierJobState {
total_count,
task_count,
location,
location_path,
cursor: FilePathIdAndLocationIdCursor {
file_path_id: 1,
file_path_id: first_path_id,
location_id: state.init.location_id,
},
});
@ -114,7 +128,7 @@ impl StatefulJob for FileIdentifierJob {
&self,
ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> JobResult {
) -> Result<(), JobError> {
let db = ctx.library_ctx().db;
// link file_path ids to a CreateFile struct containing unique file data
@ -124,19 +138,21 @@ impl StatefulJob for FileIdentifierJob {
let data = state
.data
.as_mut()
.expect("critical error: missing data on job state");
.expect("Critical error: missing data on job state");
// get chunk of orphans to process
let file_paths =
match get_orphan_file_paths(&ctx.library_ctx(), &data.cursor, data.location.id).await {
Ok(file_paths) => file_paths,
Err(e) => {
info!("Error getting orphan file paths: {:#?}", e);
return Ok(());
}
};
get_orphan_file_paths(&ctx.library_ctx(), &data.cursor, data.location.id).await?;
// if no file paths found, abort entire job early
if file_paths.is_empty() {
return Err(JobError::JobDataNotFound(
"Expected orphan Paths not returned from database query for this chunk".to_string(),
));
}
info!(
"Processing {:?} orphan files. ({} completed of {})",
"Processing {:?} orphan Paths. ({} completed of {})",
file_paths.len(),
state.step_number,
data.task_count
@ -145,7 +161,7 @@ impl StatefulJob for FileIdentifierJob {
// analyze each file_path
for file_path in &file_paths {
// get the cas_id and extract metadata
match prepare_file(&data.location_path, file_path).await {
match assemble_object_metadata(&data.location_path, file_path).await {
Ok(file) => {
let cas_id = file.cas_id.clone();
// create entry into chunks for created file data
@ -153,7 +169,7 @@ impl StatefulJob for FileIdentifierJob {
cas_lookup.insert(cas_id, file_path.id);
}
Err(e) => {
info!("Error processing file: {:#?}", e);
error!("Error assembling Object metadata: {:#?}", e);
continue;
}
};
@ -182,7 +198,7 @@ impl StatefulJob for FileIdentifierJob {
.exec()
.await
{
info!("Error updating file_id: {:#?}", e);
error!("Error updating file_id: {:#?}", e);
}
}
@ -198,65 +214,67 @@ impl StatefulJob for FileIdentifierJob {
.filter(|create_file| !existing_files_cas_ids.contains(&create_file.cas_id))
.collect::<Vec<_>>();
// assemble prisma values for new unique files
let mut values = Vec::with_capacity(new_files.len() * 3);
for file in &new_files {
values.extend([
PrismaValue::String(file.cas_id.clone()),
PrismaValue::Int(file.size_in_bytes),
PrismaValue::DateTime(file.date_created),
]);
}
if !new_files.is_empty() {
// assemble prisma values for new unique files
let mut values = Vec::with_capacity(new_files.len() * 3);
for file in &new_files {
values.extend([
PrismaValue::String(file.cas_id.clone()),
PrismaValue::Int(file.size_in_bytes),
PrismaValue::DateTime(file.date_created),
]);
}
// create new file records with assembled values
// TODO: Use create_many with skip_duplicates. Waiting on https://github.com/Brendonovich/prisma-client-rust/issues/143
let created_files: Vec<FileCreated> = db
._query_raw(Raw::new(
&format!(
"INSERT INTO files (cas_id, size_in_bytes, date_created) VALUES {}
// create new file records with assembled values
// TODO: Use create_many with skip_duplicates. Waiting on https://github.com/Brendonovich/prisma-client-rust/issues/143
let created_files: Vec<FileCreated> = db
._query_raw(Raw::new(
&format!(
"INSERT INTO files (cas_id, size_in_bytes, date_created) VALUES {}
ON CONFLICT (cas_id) DO NOTHING RETURNING id, cas_id",
vec!["({}, {}, {})"; new_files.len()].join(",")
),
values,
))
.exec()
.await
.unwrap_or_else(|e| {
error!("Error inserting files: {:#?}", e);
Vec::new()
});
for created_file in created_files {
// associate newly created files with their respective file_paths
// TODO: this is potentially bottle necking the chunk system, individually linking file_path to file, 100 queries per chunk
// - insert many could work, but I couldn't find a good way to do this in a single SQL query
if let Err(e) = db
.file_path()
.update(
file_path::location_id_id(
state.init.location_id,
*cas_lookup.get(&created_file.cas_id).unwrap(),
vec!["({}, {}, {})"; new_files.len()].join(",")
),
vec![file_path::file_id::set(Some(created_file.id))],
)
values,
))
.exec()
.await
{
info!("Error updating file_id: {:#?}", e);
.unwrap_or_else(|e| {
error!("Error inserting files: {:#?}", e);
Vec::new()
});
for created_file in created_files {
// associate newly created files with their respective file_paths
// TODO: this is potentially bottle necking the chunk system, individually linking file_path to file, 100 queries per chunk
// - insert many could work, but I couldn't find a good way to do this in a single SQL query
if let Err(e) = ctx
.library_ctx()
.db
.file_path()
.update(
file_path::location_id_id(
state.init.location_id,
*cas_lookup.get(&created_file.cas_id).unwrap(),
),
vec![file_path::file_id::set(Some(created_file.id))],
)
.exec()
.await
{
info!("Error updating file_id: {:#?}", e);
}
}
}
// handle last step
// set the step data cursor to the last row of this chunk
if let Some(last_row) = file_paths.last() {
data.cursor.file_path_id = last_row.id;
} else {
return Ok(());
}
ctx.progress(vec![
JobReportUpdate::CompletedTaskCount(state.step_number),
JobReportUpdate::Message(format!(
"Processed {} of {} orphan files",
"Processed {} of {} orphan Paths",
state.step_number * CHUNK_SIZE,
data.total_count
)),
@ -270,7 +288,7 @@ impl StatefulJob for FileIdentifierJob {
&self,
_ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> Result<(), JobError> {
) -> JobResult {
let data = state
.data
.as_ref()
@ -281,10 +299,23 @@ impl StatefulJob for FileIdentifierJob {
data.task_count
);
Ok(())
Ok(Some(serde_json::to_value(&state.init)?))
}
}
fn orphan_path_filters(location_id: i32, file_path_id: Option<i32>) -> Vec<file_path::WhereParam> {
let mut params = vec![
file_path::file_id::equals(None),
file_path::is_dir::equals(false),
file_path::location_id::equals(location_id),
];
// this is a workaround for the cursor not working properly
if let Some(file_path_id) = file_path_id {
params.push(file_path::id::gte(file_path_id))
}
params
}
#[derive(Deserialize, Serialize, Debug)]
struct CountRes {
count: Option<usize>,
@ -314,19 +345,16 @@ async fn get_orphan_file_paths(
location_id: i32,
) -> Result<Vec<file_path::Data>, prisma_client_rust::QueryError> {
info!(
"discovering {} orphan file paths at cursor: {:?}",
"Querying {} orphan Paths at cursor: {:?}",
CHUNK_SIZE, cursor
);
ctx.db
.file_path()
.find_many(vec![
file_path::file_id::equals(None),
file_path::is_dir::equals(false),
file_path::location_id::equals(location_id),
])
.find_many(orphan_path_filters(location_id, Some(cursor.file_path_id)))
.order_by(file_path::id::order(Direction::Asc))
.cursor(cursor.into())
// .cursor(cursor.into())
.take(CHUNK_SIZE as i64)
.skip(1)
.exec()
.await
}
@ -344,7 +372,7 @@ struct FileCreated {
pub cas_id: String,
}
async fn prepare_file(
async fn assemble_object_metadata(
location_path: impl AsRef<Path>,
file_path: &file_path::Data,
) -> Result<CreateFile, io::Error> {

View file

@ -8,6 +8,7 @@ use crate::{
};
use int_enum::IntEnum;
use prisma_client_rust::Direction;
use rspc::Type;
use serde::{Deserialize, Serialize};
use std::{
@ -127,6 +128,8 @@ impl JobManager {
.db
.job()
.find_many(vec![job::status::not(JobStatus::Running.int_value())])
.order_by(job::date_created::order(Direction::Desc))
.take(100)
.exec()
.await?;
@ -213,6 +216,7 @@ pub struct JobReport {
pub id: Uuid,
pub name: String,
pub data: Option<Vec<u8>>,
pub metadata: Option<serde_json::Value>,
// client_id: i32,
pub date_created: chrono::DateTime<chrono::Utc>,
pub date_modified: chrono::DateTime<chrono::Utc>,
@ -250,6 +254,12 @@ impl From<job::Data> for JobReport {
date_created: data.date_created.into(),
date_modified: data.date_modified.into(),
data: data.data,
metadata: data.metadata.and_then(|m| {
serde_json::from_slice(&m).unwrap_or_else(|e| -> Option<serde_json::Value> {
error!("Failed to deserialize job metadata: {}", e);
None
})
}),
message: String::new(),
seconds_elapsed: data.seconds_elapsed,
}
@ -267,6 +277,7 @@ impl JobReport {
status: JobStatus::Queued,
task_count: 0,
data: None,
metadata: None,
completed_task_count: 0,
message: String::new(),
seconds_elapsed: 0,
@ -295,6 +306,7 @@ impl JobReport {
vec![
job::status::set(self.status.int_value()),
job::data::set(self.data.clone()),
job::metadata::set(serde_json::to_vec(&self.metadata).ok()),
job::task_count::set(self.task_count),
job::completed_task_count::set(self.completed_task_count),
job::date_modified::set(chrono::Utc::now().into()),

View file

@ -26,6 +26,8 @@ pub enum JobError {
StateEncode(#[from] EncodeError),
#[error("Job state decode error: {0}")]
StateDecode(#[from] DecodeError),
#[error("Job metadata serialization error: {0}")]
MetadataSerialization(#[from] serde_json::Error),
#[error("Tried to resume a job with unknown name: job <name='{1}', uuid='{0}'>")]
UnknownJobName(Uuid, String),
#[error(
@ -34,11 +36,14 @@ pub enum JobError {
MissingJobDataState(Uuid, String),
#[error("Indexer error: {0}")]
IndexerError(#[from] IndexerError),
#[error("Data needed for job execution not found: job <name='{0}'>")]
JobDataNotFound(String),
#[error("Job paused")]
Paused(Vec<u8>),
}
pub type JobResult = Result<(), JobError>;
pub type JobResult = Result<JobMetadata, JobError>;
pub type JobMetadata = Option<serde_json::Value>;
#[async_trait::async_trait]
pub trait StatefulJob: Send + Sync {
@ -51,13 +56,13 @@ pub trait StatefulJob: Send + Sync {
&self,
ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> JobResult;
) -> Result<(), JobError>;
async fn execute_step(
&self,
ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> JobResult;
) -> Result<(), JobError>;
async fn finalize(
&self,
@ -149,6 +154,7 @@ where
fn name(&self) -> &'static str {
self.stateful_job.name()
}
async fn run(&mut self, ctx: WorkerContext) -> JobResult {
// Checking if we have a brand new job, or if we are resuming an old one.
if self.state.data.is_none() {
@ -181,8 +187,6 @@ where
self.stateful_job
.finalize(ctx.clone(), &mut self.state)
.await?;
Ok(())
.await
}
}

View file

@ -13,13 +13,16 @@ use tokio::{
};
use tracing::{error, info, warn};
use super::JobReport;
use super::{JobMetadata, JobReport};
// used to update the worker state from inside the worker thread
#[derive(Debug)]
pub enum WorkerEvent {
Progressed(Vec<JobReportUpdate>),
Completed(oneshot::Sender<()>),
Progressed {
updates: Vec<JobReportUpdate>,
debounce: bool,
},
Completed(oneshot::Sender<()>, JobMetadata),
Failed(oneshot::Sender<()>),
Paused(Vec<u8>, oneshot::Sender<()>),
}
@ -34,7 +37,18 @@ pub struct WorkerContext {
impl WorkerContext {
pub fn progress(&self, updates: Vec<JobReportUpdate>) {
self.events_tx
.send(WorkerEvent::Progressed(updates))
.send(WorkerEvent::Progressed {
updates,
debounce: false,
})
.expect("critical error: failed to send worker worker progress event updates");
}
pub fn progress_debounced(&self, updates: Vec<JobReportUpdate>) {
self.events_tx
.send(WorkerEvent::Progressed {
updates,
debounce: true,
})
.expect("critical error: failed to send worker worker progress event updates");
}
@ -124,9 +138,10 @@ impl Worker {
loop {
interval.tick().await;
if events_tx
.send(WorkerEvent::Progressed(vec![
JobReportUpdate::SecondsElapsed(1),
]))
.send(WorkerEvent::Progressed {
updates: vec![JobReportUpdate::SecondsElapsed(1)],
debounce: false,
})
.is_err() && events_tx.is_closed()
{
break;
@ -136,25 +151,27 @@ impl Worker {
let (done_tx, done_rx) = oneshot::channel();
if let Err(e) = job.run(worker_ctx.clone()).await {
if let JobError::Paused(state) = e {
match job.run(worker_ctx.clone()).await {
Ok(metadata) => {
// handle completion
worker_ctx
.events_tx
.send(WorkerEvent::Completed(done_tx, metadata))
.expect("critical error: failed to send worker complete event");
}
Err(JobError::Paused(state)) => {
worker_ctx
.events_tx
.send(WorkerEvent::Paused(state, done_tx))
.expect("critical error: failed to send worker pause event");
} else {
}
Err(e) => {
error!("job '{}' failed with error: {:#?}", job_id, e);
worker_ctx
.events_tx
.send(WorkerEvent::Failed(done_tx))
.expect("critical error: failed to send worker fail event");
}
} else {
// handle completion
worker_ctx
.events_tx
.send(WorkerEvent::Completed(done_tx))
.expect("critical error: failed to send worker complete event");
}
if let Err(e) = done_rx.await {
@ -171,17 +188,27 @@ impl Worker {
mut worker_events_rx: UnboundedReceiver<WorkerEvent>,
library: LibraryContext,
) {
let mut last = Instant::now();
while let Some(command) = worker_events_rx.recv().await {
let mut worker = worker.lock().await;
match command {
WorkerEvent::Progressed(changes) => {
WorkerEvent::Progressed { updates, debounce } => {
if debounce {
let current = Instant::now();
if current.duration_since(last) > Duration::from_millis(1000 / 60) {
last = current
} else {
continue;
}
}
// protect against updates if job is not running
if worker.report.status != JobStatus::Running {
continue;
};
for change in changes {
match change {
for update in updates {
match update {
JobReportUpdate::TaskCount(task_count) => {
worker.report.task_count = task_count as i32;
}
@ -199,9 +226,10 @@ impl Worker {
invalidate_query!(library, "jobs.getRunning");
}
WorkerEvent::Completed(done_tx) => {
WorkerEvent::Completed(done_tx, metadata) => {
worker.report.status = JobStatus::Completed;
worker.report.data = None;
worker.report.metadata = metadata;
if let Err(e) = worker.report.update(&library).await {
error!("failed to update job report: {:#?}", e);
}

View file

@ -2,7 +2,7 @@ use api::{CoreEvent, Ctx, Router};
use job::JobManager;
use library::LibraryManager;
use node::NodeConfigManager;
use std::{path::Path, sync::Arc};
use std::{fs::File, io::Read, path::Path, sync::Arc};
use tracing::{error, info};
use tracing_subscriber::{filter::LevelFilter, fmt, prelude::*, EnvFilter};
@ -115,6 +115,51 @@ impl Node {
}
}
// Note: this system doesn't use chunked encoding which could prove a problem with large files but I can't see an easy way to do chunked encoding with Tauri custom URIs.
// It would also be nice to use Tokio Filesystem operations instead of the std ones which block. Tauri's custom URI protocols don't seem to support async out of the box.
pub fn handle_custom_uri(
&self,
path: Vec<&str>,
) -> (
u16, /* Status Code */
&str, /* Content-Type */
Vec<u8>, /* Body */
) {
match path.first().copied() {
Some("thumbnail") => {
if path.len() != 2 {
return (
400,
"text/html",
b"Bad Request: Invalid number of parameters".to_vec(),
);
}
let filename = Path::new(&self.config.data_directory())
.join("thumbnails")
.join(path[1] /* file_cas_id */)
.with_extension("webp");
match File::open(&filename) {
Ok(mut file) => {
let mut buf = match std::fs::metadata(&filename) {
Ok(metadata) => Vec::with_capacity(metadata.len() as usize),
Err(_) => Vec::new(),
};
file.read_to_end(&mut buf).unwrap();
(200, "image/webp", buf)
}
Err(_) => (404, "text/html", b"File Not Found".to_vec()),
}
}
_ => (
400,
"text/html",
b"Bad Request: Invalid operation!".to_vec(),
),
}
}
pub async fn shutdown(&self) {
info!("Spacedrive shutting down...");
self.jobs.pause().await;

View file

@ -111,20 +111,14 @@ impl LibraryManager {
node_context,
});
// TODO: Remove this before merging PR -> Currently it exists to make the app usable
if this.libraries.read().await.len() == 0 {
this.create(LibraryConfig {
name: "My Default Library".into(),
..Default::default()
})
.await?;
}
Ok(this)
}
/// create creates a new library with the given config and mounts it into the running [LibraryManager].
pub(crate) async fn create(&self, config: LibraryConfig) -> Result<(), LibraryManagerError> {
pub(crate) async fn create(
&self,
config: LibraryConfig,
) -> Result<LibraryConfigWrapped, LibraryManagerError> {
let id = Uuid::new_v4();
LibraryConfig::save(
Path::new(&self.libraries_dir).join(format!("{id}.sdlibrary")),
@ -135,7 +129,7 @@ impl LibraryManager {
let library = Self::load(
id,
self.libraries_dir.join(format!("{id}.db")),
config,
config.clone(),
self.node_context.clone(),
)
.await?;
@ -143,7 +137,7 @@ impl LibraryManager {
invalidate_query!(library, "library.list");
self.libraries.write().await.push(library);
Ok(())
Ok(LibraryConfigWrapped { uuid: id, config })
}
pub(crate) async fn get_all_libraries_config(&self) -> Vec<LibraryConfigWrapped> {

View file

@ -1,5 +1,5 @@
use crate::{
job::{JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext},
job::{JobError, JobReportUpdate, JobResult, JobState, StatefulJob, WorkerContext},
prisma::{file_path, location},
};
@ -70,7 +70,7 @@ pub struct IndexerJobStepEntry {
impl IndexerJobData {
fn on_scan_progress(ctx: WorkerContext, progress: Vec<ScanProgress>) {
ctx.progress(
ctx.progress_debounced(
progress
.iter()
.map(|p| match p.clone() {
@ -98,7 +98,7 @@ impl StatefulJob for IndexerJob {
&self,
ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> JobResult {
) -> Result<(), JobError> {
let location_path = state
.init
.location
@ -225,7 +225,7 @@ impl StatefulJob for IndexerJob {
&self,
ctx: WorkerContext,
state: &mut JobState<Self::Init, Self::Data, Self::Step>,
) -> JobResult {
) -> Result<(), JobError> {
let location_path = &state
.data
.as_ref()
@ -303,7 +303,7 @@ impl StatefulJob for IndexerJob {
.expect("critical error: non-negative duration"),
);
Ok(())
Ok(Some(serde_json::to_value(state)?))
}
}

View file

@ -37,7 +37,10 @@ pub struct LocationCreateArgs {
}
impl LocationCreateArgs {
pub async fn create(self, ctx: &LibraryContext) -> Result<location::Data, LocationError> {
pub async fn create(
self,
ctx: &LibraryContext,
) -> Result<indexer_job_location::Data, LocationError> {
// check if we have access to this location
if !self.path.exists() {
return Err(LocationError::PathNotFound(self.path));
@ -75,6 +78,7 @@ impl LocationCreateArgs {
location::local_path::set(Some(self.path.to_string_lossy().to_string())),
],
)
.include(indexer_job_location::include())
.exec()
.await?;
@ -86,6 +90,7 @@ impl LocationCreateArgs {
// Updating our location variable to include information about the indexer rules
location = fetch_location(ctx, location.id)
.include(indexer_job_location::include())
.exec()
.await?
.ok_or(LocationError::IdNotFound(location.id))?;
@ -232,35 +237,28 @@ async fn link_location_and_indexer_rules(
Ok(())
}
pub async fn scan_location(ctx: &LibraryContext, location_id: i32) -> Result<(), LocationError> {
let location = ctx
.db
.location()
.find_unique(location::id::equals(location_id))
.include(indexer_job_location::include())
.exec()
.await?
.ok_or(LocationError::IdNotFound(location_id))?;
pub async fn scan_location(
ctx: &LibraryContext,
location: indexer_job_location::Data,
) -> Result<(), LocationError> {
if location.local_path.is_none() {
return Err(LocationError::MissingLocalPath(location.id));
};
ctx.spawn_job(Job::new(
IndexerJobInit { location },
Box::new(IndexerJob {}),
))
.await;
let location_id = location.id;
ctx.queue_job(Job::new(
FileIdentifierJobInit {
location_id,
location_id: location.id,
sub_path: None,
},
Box::new(FileIdentifierJob {}),
))
.await;
ctx.spawn_job(Job::new(
IndexerJobInit { location },
Box::new(IndexerJob {}),
))
.await;
ctx.queue_job(Job::new(
ThumbnailJobInit {
location_id,

View file

@ -1,3 +1,7 @@
# Nodes
Nodes are instances of the Spacedrive core running on a device, they are able to connect to each other via a peer-to-peer network. A node is able to run many libraries simultaneously, but must be authorized per-library in order to synchronize.
p2p, connecting nodes, protocols.

View file

@ -2,6 +2,6 @@
Spacedrive generates compressed preview media for images, videos and text files.
Preview media is stored in the Node's data folder in a single directory. Images are stored as WEBP format with their CAS id as the name.
ffmpeg, syncing, security

View file

@ -26,6 +26,8 @@
"eventemitter3": "^4.0.7",
"immer": "^9.0.15",
"lodash": "^4.17.21",
"valtio": "^1.7.0",
"valtio-persist": "^1.0.2",
"zustand": "4.0.0"
},
"devDependencies": {

View file

@ -1,22 +0,0 @@
import { createContext, useContext } from 'react';
export const AppPropsContext = createContext<AppProps | null>(null);
export type Platform = 'browser' | 'macOS' | 'windows' | 'linux' | 'unknown';
export type CdnUrl = 'internal' | string;
export const useAppProps = () => useContext(AppPropsContext);
export interface AppProps {
platform: Platform;
cdn_url?: CdnUrl;
data_path?: string;
convertFileSrc: (url: string) => string;
openDialog: (options: { directory?: boolean }) => Promise<string | string[] | null>;
onClose?: () => void;
onMinimize?: () => void;
onFullscreen?: () => void;
onOpen?: (path: string) => void;
isFocused?: boolean;
demoMode?: boolean;
}

View file

@ -0,0 +1,37 @@
import { PropsWithChildren, createContext, useContext } from 'react';
export type OperatingSystem = 'browser' | 'linux' | 'macOS' | 'windows' | 'unknown';
// Platform represents the underlying native layer the app is running on.
// This could be Tauri or web.
export type Platform = {
platform: 'web' | 'tauri'; // This represents the specific platform implementation
getThumbnailUrlById: (casId: string) => string;
openLink: (url: string) => void;
demoMode?: boolean; // TODO: Remove this in favour of demo mode being handled at the React Query level
getOs?(): Promise<OperatingSystem>;
openFilePickerDialog?(): Promise<null | string | string[]>;
};
// Keep this private and use through helpers below
const context = createContext<Platform>(undefined!);
// is a hook which allows you to fetch information about the current platform from the React context.
export function usePlatform(): Platform {
const ctx = useContext(context);
if (!ctx)
throw new Error(
"The 'PlatformProvider' has not been mounted above the current 'usePlatform' call."
);
return ctx;
}
// provides the platform context to the rest of the app through React context.
// Mount it near the top of your component tree.
export function PlatformProvider({
platform,
children
}: PropsWithChildren<{ platform: Platform }>) {
return <context.Provider value={platform}>{children}</context.Provider>;
}

View file

@ -1,2 +1,2 @@
export * from './AppPropsContext';
export * from './LocationContext';
export * from './Platform';

View file

@ -0,0 +1 @@
export * from './useCurrentLibrary';

View file

@ -0,0 +1,80 @@
import { PropsWithChildren, createContext, useCallback, useContext, useMemo } from 'react';
import { proxy, useSnapshot } from 'valtio';
import { useBridgeQuery, useExplorerStore } from '../index';
// The name of the localStorage key for caching library data
const libraryCacheLocalStorageKey = 'sd-library-list';
type OnNoLibraryFunc = () => void | Promise<void>;
// Keep this private and use `useCurrentLibrary` hook to access or mutate it
const currentLibraryUuidStore = proxy({ id: null as string | null });
const CringeContext = createContext<{
onNoLibrary: OnNoLibraryFunc;
}>(undefined!);
export const LibraryContextProvider = ({
onNoLibrary,
children
}: PropsWithChildren<{ onNoLibrary: OnNoLibraryFunc }>) => {
return <CringeContext.Provider value={{ onNoLibrary }}>{children}</CringeContext.Provider>;
};
// this is a hook to get the current library loaded into the UI. It takes care of a bunch of invariants under the hood.
export const useCurrentLibrary = () => {
const explorerStore = useExplorerStore();
const currentLibraryUuid = useSnapshot(currentLibraryUuidStore).id;
const ctx = useContext(CringeContext);
if (ctx === undefined)
throw new Error(
"The 'LibraryContextProvider' was not mounted and you attempted do use the 'useCurrentLibrary' hook. Please add the provider in your component tree."
);
const { data: libraries, isLoading } = useBridgeQuery(['library.list'], {
keepPreviousData: true,
initialData: () => {
const cachedData = localStorage.getItem(libraryCacheLocalStorageKey);
if (cachedData) {
// If we fail to load cached data, it's fine
try {
return JSON.parse(cachedData);
} catch (e) {
console.error("Error loading cached 'sd-library-list' data", e);
}
}
return undefined;
},
onSuccess: (data) => {
localStorage.setItem(libraryCacheLocalStorageKey, JSON.stringify(data));
// Redirect to the onboarding flow if the user doesn't have any libraries
if (libraries?.length === 0) {
ctx.onNoLibrary();
}
}
});
const switchLibrary = useCallback((libraryUuid: string) => {
currentLibraryUuidStore.id = libraryUuid;
explorerStore.reset();
}, []);
// memorize library to avoid re-running find function
const library = useMemo(() => {
const current = libraries?.find((l: any) => l.uuid === currentLibraryUuid);
// switch to first library if none set
if (libraries && !current && libraries[0]?.uuid) {
switchLibrary(libraries[0]?.uuid);
}
return current;
}, [libraries, currentLibraryUuid]); // TODO: This runs when the 'libraries' change causing the whole app to re-render which is cringe.
return {
library,
libraries,
isLoading,
switchLibrary
};
};

View file

@ -1,4 +1,5 @@
export * from './stores';
export * from './context';
export * from './rspc';
export * from './hooks';
export type { Operations } from '@sd/core';

View file

@ -3,6 +3,8 @@ import { createReactQueryHooks } from '@rspc/react';
import { LibraryArgs, Operations } from '@sd/core';
import {
QueryClient,
UseInfiniteQueryOptions,
UseInfiniteQueryResult,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
@ -10,7 +12,7 @@ import {
useMutation as _useMutation
} from '@tanstack/react-query';
import { useLibraryStore } from './stores';
import { useCurrentLibrary } from './index';
export const queryClient = new QueryClient();
export const rspc = createReactQueryHooks<Operations>();
@ -41,10 +43,30 @@ export function useLibraryQuery<K extends LibraryQueryKey>(
key: LibraryQueryArgs<K> extends null | undefined ? [K] : [K, LibraryQueryArgs<K>],
options?: UseQueryOptions<LibraryQueryResult<K>, RSPCError>
): UseQueryResult<LibraryQueryResult<K>, RSPCError> {
const library_id = useLibraryStore((state) => state.currentLibraryUuid);
if (!library_id) throw new Error(`Attempted to do library query with no library set!`);
const { library } = useCurrentLibrary();
if (!library?.uuid) throw new Error(`Attempted to do library query with no library set!`);
// @ts-ignore
return rspc.useQuery([key[0], { library_id: library_id || '', arg: key[1] || null }], options);
return rspc.useQuery(
// @ts-ignore
[key[0], { library_id: library?.uuid || '', arg: key[1] || null }],
options
);
}
export function useInfiniteLibraryQuery<K extends LibraryQueryKey>(
key: LibraryQueryArgs<K> extends null | undefined ? [K] : [K, LibraryQueryArgs<K>],
options?: UseInfiniteQueryOptions<LibraryQueryResult<K>, RSPCError>
): UseInfiniteQueryResult<LibraryQueryResult<K>, RSPCError> {
const { library } = useCurrentLibrary();
if (!library?.uuid) throw new Error(`Attempted to do library query with no library set!`);
// @ts-ignore
return rspc.useInfiniteQuery(
// @ts-ignore
[key[0], { library_id: library?.uuid || '', arg: key[1] || null }],
options
);
}
type LibraryMutations = Extract<Operations['mutations'], { key: [string, LibraryArgs<any>] }>;
@ -58,12 +80,13 @@ export function useLibraryMutation<K extends LibraryMutationKey>(
options?: UseMutationOptions<LibraryMutationResult<K>, RSPCError>
) {
const ctx = rspc.useContext();
const library_id = useLibraryStore((state) => state.currentLibraryUuid);
if (!library_id) throw new Error(`Attempted to do library query with no library set!`);
const { library } = useCurrentLibrary();
if (!library?.uuid) throw new Error(`Attempted to do library query with no library set!`);
// @ts-ignore
return _useMutation<LibraryMutationResult<K>, RSPCError, LibraryMutationArgs<K>>(
async (data) => ctx.client.mutation([key, { library_id: library_id || '', arg: data || null }]),
async (data) =>
ctx.client.mutation([key, { library_id: library?.uuid || '', arg: data || null }]),
{
...options,
context: rspc.ReactQueryContext

View file

@ -0,0 +1,49 @@
import { proxy, useSnapshot } from 'valtio';
import { resetStore } from './util';
export type ExplorerLayoutMode = 'list' | 'grid';
export enum ExplorerKind {
Location,
Tag,
Space
}
const state = {
locationId: null as number | null,
layoutMode: 'grid' as ExplorerLayoutMode,
gridItemSize: 100,
listItemSize: 40,
selectedRowIndex: 1,
showInspector: true,
multiSelectIndexes: [] as number[],
contextMenuObjectId: null as number | null,
newThumbnails: {} as Record<string, boolean>
};
// Keep the private and use `useExplorerState` or `getExplorerStore` or you will get production build issues.
const explorerStore = proxy({
...state,
reset: () => resetStore(explorerStore, state),
addNewThumbnail: (cas_id: string) => {
explorerStore.newThumbnails[cas_id] = true;
},
selectMore: (indexes: number[]) => {
if (!explorerStore.multiSelectIndexes.length && indexes.length) {
explorerStore.multiSelectIndexes = [explorerStore.selectedRowIndex, ...indexes];
} else {
explorerStore.multiSelectIndexes = [
...new Set([...explorerStore.multiSelectIndexes, ...indexes])
];
}
}
});
export function useExplorerStore() {
return useSnapshot(explorerStore);
}
export function getExplorerStore() {
return explorerStore;
}

View file

@ -1,2 +1 @@
export * from './useLibraryStore';
export * from './useExplorerStore';
export * from './explorerStore';

View file

@ -1,53 +0,0 @@
import produce from 'immer';
import create from 'zustand';
type LayoutMode = 'list' | 'grid';
export enum ExplorerKind {
Location,
Tag,
Space
}
type ExplorerStore = {
layoutMode: LayoutMode;
locationId: number | null; // used by top bar
showInspector: boolean;
selectedRowIndex: number;
multiSelectIndexes: number[];
contextMenuObjectId: number | null;
newThumbnails: Record<string, boolean>;
addNewThumbnail: (cas_id: string) => void;
selectMore: (indexes: number[]) => void;
reset: () => void;
set: (changes: Partial<ExplorerStore>) => void;
};
export const useExplorerStore = create<ExplorerStore>((set) => ({
layoutMode: 'grid',
locationId: null,
showInspector: true,
selectedRowIndex: 1,
multiSelectIndexes: [],
contextMenuObjectId: -1,
newThumbnails: {},
addNewThumbnail: (cas_id) =>
set((state) =>
produce(state, (draft) => {
draft.newThumbnails[cas_id] = true;
})
),
selectMore: (indexes) => {
set((state) =>
produce(state, (draft) => {
if (!draft.multiSelectIndexes.length && indexes.length) {
draft.multiSelectIndexes = [draft.selectedRowIndex, ...indexes];
} else {
draft.multiSelectIndexes = [...new Set([...draft.multiSelectIndexes, ...indexes])];
}
})
);
},
reset: () => set(() => ({})),
set: (changes) => set((state) => ({ ...state, ...changes }))
}));

View file

@ -1,68 +0,0 @@
import { useBridgeQuery } from '../index';
import { useExplorerStore } from './useExplorerStore';
import { LibraryConfigWrapped } from '@sd/core';
import produce from 'immer';
import { useMemo } from 'react';
import create from 'zustand';
import { devtools, persist } from 'zustand/middleware';
type LibraryStore = {
// the uuid of the currently active library
currentLibraryUuid: string | null;
// for full functionality this should be triggered along-side query invalidation
switchLibrary: (uuid: string) => void;
// a function
init: (libraries: LibraryConfigWrapped[]) => Promise<void>;
};
export const useLibraryStore = create<LibraryStore>()(
devtools(
persist(
(set) => ({
currentLibraryUuid: null,
switchLibrary: (uuid) => {
set((state) =>
produce(state, (draft) => {
draft.currentLibraryUuid = uuid;
})
);
// reset other stores
useExplorerStore().reset();
},
init: async (libraries) => {
set((state) =>
produce(state, (draft) => {
// use first library default if none set
if (!state.currentLibraryUuid) {
draft.currentLibraryUuid = libraries[0].uuid;
}
})
);
}
}),
{ name: 'sd-library-store' }
)
)
);
// this must be used at least once in the app to correct the initial state
// is memorized and can be used safely in any component
export const useCurrentLibrary = () => {
const { currentLibraryUuid, switchLibrary } = useLibraryStore();
const { data: libraries } = useBridgeQuery(['library.list'], {
onSuccess: (data) => {},
onError: (err) => {}
});
// memorize library to avoid re-running find function
const currentLibrary = useMemo(() => {
const current = libraries?.find((l) => l.uuid === currentLibraryUuid);
// switch to first library if none set
if (Array.isArray(libraries) && !current && libraries[0]?.uuid) {
switchLibrary(libraries[0]?.uuid);
}
return current;
}, [libraries, currentLibraryUuid]);
return { currentLibrary, libraries, currentLibraryUuid };
};

View file

@ -0,0 +1,18 @@
import { ProxyPersistStorageEngine } from 'valtio-persist';
export function resetStore<T extends Record<string, any>, E extends Record<string, any>>(
store: T,
defaults: E
) {
for (const key in defaults) {
// @ts-ignore
store[key] = defaults[key];
}
}
export const storageEngine: ProxyPersistStorageEngine = {
getItem: (name) => window.localStorage.getItem(name),
setItem: (name, value) => window.localStorage.setItem(name, value),
removeItem: (name) => window.localStorage.removeItem(name),
getAllKeys: () => Object.keys(window.localStorage)
};

View file

@ -15,7 +15,6 @@
"lint": "eslint src/**/*.{ts,tsx} && tsc --noEmit"
},
"dependencies": {
"@apollo/client": "^3.6.9",
"@fontsource/inter": "^4.5.11",
"@headlessui/react": "^1.6.6",
"@heroicons/react": "^2.0.10",
@ -24,19 +23,22 @@
"@radix-ui/react-icons": "^1.1.1",
"@radix-ui/react-progress": "^0.1.4",
"@radix-ui/react-slider": "^0.1.4",
"@radix-ui/react-tabs": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@sd/assets": "workspace:*",
"@sd/client": "workspace:*",
"@sd/core": "workspace:*",
"@sd/ui": "workspace:*",
"@tailwindcss/forms": "^0.5.2",
"@tanstack/react-query": "^4.0.10",
"@tanstack/react-query": "^4.2.3",
"@tanstack/react-query-devtools": "^4.0.10",
"@tanstack/react-virtual": "3.0.0-beta.18",
"@types/styled-components": "^5.1.25",
"@vitejs/plugin-react": "^2.0.0",
"autoprefixer": "^10.4.7",
"byte-size": "^8.1.0",
"clsx": "^1.2.1",
"date-fns": "^2.29.2",
"immer": "^9.0.15",
"jotai": "^1.7.6",
"lodash": "^4.17.21",
@ -67,6 +69,8 @@
"tailwindcss": "^3.1.6",
"use-count-up": "^3.0.1",
"use-debounce": "^8.0.3",
"valtio": "^1.7.0",
"valtio-persist": "^1.0.2",
"zod": "^3.18.0",
"zustand": "4.0.0"
},

View file

@ -1,50 +1,15 @@
import '@fontsource/inter/variable.css';
import {
AppProps,
AppPropsContext,
queryClient,
useBridgeQuery,
useInvalidateQuery
} from '@sd/client';
import { LibraryContextProvider, queryClient } from '@sd/client';
import { QueryClientProvider, defaultContext } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import React, { useEffect, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { AppRouter } from './AppRouter';
import { ErrorFallback } from './ErrorFallback';
import './style.scss';
function RouterContainer(props: { props: AppProps }) {
const [appProps, setAppProps] = useState(props.props);
const { data: client } = useBridgeQuery(['getNode']);
useEffect(() => {
setAppProps((appProps) => ({
...appProps,
data_path: client?.data_path
}));
}, [client?.data_path]);
return (
<AppPropsContext.Provider value={Object.assign({ isFocused: true }, appProps)}>
<MemoryRouter>
<AppRouter />
</MemoryRouter>
</AppPropsContext.Provider>
);
}
export default function SpacedriveInterface(props: AppProps) {
useInvalidateQuery();
// hotfix for bug where props are not updated, not sure of the cause
if (props.platform === 'unknown') {
// this should be a loading screen if we can't fix the issue above
return <></>;
}
export default function SpacedriveInterface() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<QueryClientProvider client={queryClient} contextSharing={true}>
@ -52,8 +17,21 @@ export default function SpacedriveInterface(props: AppProps) {
{import.meta.env.MODE === 'development' && (
<ReactQueryDevtools position="bottom-right" context={defaultContext} />
)}
<RouterContainer props={props} />
<MemoryRouter>
<AppRouterWrapper />
</MemoryRouter>
</QueryClientProvider>
</ErrorBoundary>
);
}
// This can't go in `<SpacedriveInterface />` cause it needs the router context but it can't go in `<AppRouter />` because that requires this context
function AppRouterWrapper() {
const navigate = useNavigate();
return (
<LibraryContextProvider onNoLibrary={() => navigate('/onboarding')}>
<AppRouter />
</LibraryContextProvider>
);
}

View file

@ -1,15 +1,18 @@
import { useAppProps } from '@sd/client';
import { useCurrentLibrary } from '@sd/client';
import clsx from 'clsx';
import React from 'react';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './components/layout/Sidebar';
import { useOperatingSystem } from './hooks/useOperatingSystem';
export function AppLayout() {
const appProps = useAppProps();
const { libraries } = useCurrentLibrary();
const os = useOperatingSystem();
const isWindowRounded = appProps?.platform === 'macOS';
const hasWindowBorder = appProps?.platform !== 'browser' && appProps?.platform !== 'windows';
// This will ensure nothing is rendered while the `useCurrentLibrary` hook navigates to the onboarding page. This prevents requests with an invalid library id being sent to the backend
if (libraries?.length === 0) {
return null;
}
return (
<div
@ -20,8 +23,8 @@ export function AppLayout() {
}}
className={clsx(
'flex flex-row h-screen overflow-hidden text-gray-900 select-none dark:text-white cursor-default',
isWindowRounded && 'rounded-xl',
hasWindowBorder && 'border border-gray-200 dark:border-gray-500'
os === 'macOS' && 'rounded-xl',
os !== 'browser' && os !== 'windows' && 'border border-gray-200 dark:border-gray-500'
)}
>
<Sidebar />

View file

@ -1,9 +1,10 @@
import { useBridgeQuery, useLibraryStore } from '@sd/client';
import React, { useEffect } from 'react';
import { Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { useCurrentLibrary, useInvalidateQuery } from '@sd/client';
import { Route, Routes } from 'react-router-dom';
import { AppLayout } from './AppLayout';
import { NotFound } from './NotFound';
import OnboardingScreen from './components/onboarding/Onboarding';
import { useKeybindHandler } from './hooks/useKeyboardHandler';
import { ContentScreen } from './screens/Content';
import { DebugScreen } from './screens/Debug';
import { LocationExplorer } from './screens/LocationExplorer';
@ -32,45 +33,27 @@ import TagsSettings from './screens/settings/library/TagsSettings';
import ExperimentalSettings from './screens/settings/node/ExperimentalSettings';
import LibrarySettings from './screens/settings/node/LibrariesSettings';
import P2PSettings from './screens/settings/node/P2PSettings';
import { KeybindEvent } from './util/keybind';
export function AppRouter() {
const location = useLocation();
const state = location.state as { backgroundLocation?: Location };
const libraryState = useLibraryStore();
const navigate = useNavigate();
const { data: libraries } = useBridgeQuery(['library.list']);
const { library } = useCurrentLibrary();
// TODO: This can be removed once we add a setup flow to the app
useEffect(() => {
if (libraryState.currentLibraryUuid === null && libraries && libraries.length > 0) {
libraryState.switchLibrary(libraries[0].uuid);
}
}, [libraryState, libraryState.currentLibraryUuid, libraries]);
useEffect(() => {
const handleKeybind = (e: KeybindEvent) => {
if (e.detail.action === 'open_settings') {
navigate('/settings');
e.preventDefault();
return;
}
};
document.addEventListener('keybindexec', handleKeybind);
return () => document.removeEventListener('keybindexec', handleKeybind);
}, [navigate]);
useKeybindHandler();
useInvalidateQuery();
return (
<>
{libraryState.currentLibraryUuid === null ? (
<>
{/* TODO: Remove this when adding app setup flow */}
<h1>No Library Loaded...</h1>
</>
) : (
<Routes location={state?.backgroundLocation || location}>
<Route path="/" element={<AppLayout />}>
<Routes>
<Route path="onboarding" element={<OnboardingScreen />} />
<Route element={<AppLayout />}>
{/* As we are caching the libraries in localStore so this *shouldn't* result is visual problems unless something else is wrong */}
{library === undefined ? (
<Route
path="*"
element={
<h1 className="text-white p-4">Please select or create a library in the sidebar.</h1>
}
/>
) : (
<>
<Route index element={<RedirectPage to="/overview" />} />
<Route path="overview" element={<OverviewScreen />} />
<Route path="content" element={<ContentScreen />} />
@ -105,9 +88,9 @@ export function AppRouter() {
<Route path="location/:id" element={<LocationExplorer />} />
<Route path="tag/:id" element={<TagExplorer />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
)}
</>
</>
)}
</Route>
</Routes>
);
}

View file

@ -1,8 +1,13 @@
import { rspc, usePlatform } from '@sd/client';
import { Button } from '@sd/ui';
import React from 'react';
import { FallbackProps } from 'react-error-boundary';
import { guessOperatingSystem } from './hooks/useOperatingSystem';
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
const platform = usePlatform();
const version = 'unknown'; // TODO: Embed the version into the frontend via ENV var when compiled so we can use it here.
return (
<div
data-tauri-drag-region
@ -16,7 +21,21 @@ export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
<Button variant="primary" className="mt-2" onClick={resetErrorBoundary}>
Reload
</Button>
<Button variant="gray" className="mt-2" onClick={resetErrorBoundary}>
<Button
variant="gray"
className="mt-2"
onClick={() => {
platform.openLink(
`https://github.com/spacedriveapp/spacedrive/issues/new?assignees=&labels=kind%2Fbug%2Cstatus%2Fneeds-triage&template=bug_report.yml&logs=${encodeURIComponent(
error.toString()
)}&info=${encodeURIComponent(
`App version ${version} running on ${guessOperatingSystem() || 'unknown'}`
)}`
);
resetErrorBoundary();
}}
>
Send report
</Button>
</div>

View file

@ -1,5 +1,4 @@
import { Button } from '@sd/ui';
import React from 'react';
import { useNavigate } from 'react-router';
export function NotFound() {

View file

@ -2,7 +2,7 @@ import { KeyIcon } from '@heroicons/react/24/outline';
import { CogIcon, LockClosedIcon } from '@heroicons/react/24/solid';
import { Button } from '@sd/ui';
import { Cloud, Desktop, DeviceMobileCamera, DotsSixVertical, Laptop } from 'phosphor-react';
import React, { useState } from 'react';
import { useState } from 'react';
import FileItem from '../explorer/FileItem';
import Loader from '../primitive/Loader';

View file

@ -1,22 +1,33 @@
import { useBridgeMutation } from '@sd/client';
import { Input } from '@sd/ui';
import React, { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { PropsWithChildren, useState } from 'react';
import Dialog from '../layout/Dialog';
interface Props {
children: React.ReactNode;
}
export default function CreateLibraryDialog(props: Props) {
export default function CreateLibraryDialog({
children,
onSubmit
}: PropsWithChildren<{ onSubmit?: () => void }>) {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [newLibName, setNewLibName] = useState('');
const queryClient = useQueryClient();
const { mutate: createLibrary, isLoading: createLibLoading } = useBridgeMutation(
'library.create',
{
onSuccess: () => {
onSuccess: (library) => {
console.log('SUBMITTING');
setOpenCreateModal(false);
queryClient.setQueryData(['library.list'], (libraries: any) => [
...(libraries || []),
library
]);
if (onSubmit) onSubmit();
},
onError: (err) => {
console.error(err);
}
}
);
@ -31,7 +42,7 @@ export default function CreateLibraryDialog(props: Props) {
loading={createLibLoading}
submitDisabled={!newLibName}
ctaLabel="Create"
trigger={props.children}
trigger={children}
>
<Input
className="flex-grow w-full mt-3"

View file

@ -1,7 +1,5 @@
import { useBridgeMutation } from '@sd/client';
import { LibraryConfigWrapped } from '@sd/core';
import { Input } from '@sd/ui';
import React, { useState } from 'react';
import { useState } from 'react';
import Dialog from '../layout/Dialog';

View file

@ -1,212 +1,45 @@
import {
rspc,
useExplorerStore,
useLibraryMutation,
useLibraryQuery,
useLibraryStore
} from '@sd/client';
import { getExplorerStore, rspc, useCurrentLibrary } from '@sd/client';
import { ExplorerData } from '@sd/core';
import {
ArrowBendUpRight,
LockSimple,
Package,
Plus,
Share,
TagSimple,
Trash,
TrashSimple
} from 'phosphor-react';
import React from 'react';
import { FileList } from '../explorer/FileList';
import { Inspector } from '../explorer/Inspector';
import { WithContextMenu } from '../layout/MenuOverlay';
import { TopBar } from '../layout/TopBar';
import ExplorerContextMenu from './ExplorerContextMenu';
import { VirtualizedList } from './VirtualizedList';
interface Props {
data: ExplorerData;
}
export default function Explorer(props: Props) {
const { selectedRowIndex, addNewThumbnail, contextMenuObjectId, showInspector } =
useExplorerStore();
const expStore = getExplorerStore();
const { library } = useCurrentLibrary();
const { currentLibraryUuid } = useLibraryStore();
const { data: tags } = useLibraryQuery(['tags.list'], {});
const { mutate: assignTag } = useLibraryMutation('tags.assign');
const { data: tagsForFile } = useLibraryQuery(['tags.getForFile', contextMenuObjectId || -1]);
rspc.useSubscription(['jobs.newThumbnail', { library_id: currentLibraryUuid!, arg: null }], {
rspc.useSubscription(['jobs.newThumbnail', { library_id: library!.uuid, arg: null }], {
onNext: (cas_id) => {
addNewThumbnail(cas_id);
expStore.addNewThumbnail(cas_id);
}
});
return (
<div className="relative">
<WithContextMenu
menu={[
[
// `file-${props.identifier}`,
{
label: 'Open'
},
{
label: 'Open with...'
}
],
[
{
label: 'Quick view'
},
{
label: 'Open in Finder'
}
],
[
{
label: 'Rename'
},
{
label: 'Duplicate'
}
],
[
{
label: 'Share',
icon: Share,
onClick(e) {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}
}
],
[
{
label: 'Assign tag',
icon: TagSimple,
children: [
tags?.map((tag) => {
const active = !!tagsForFile?.find((t) => t.id === tag.id);
return {
label: tag.name || '',
// leftItem: <Checkbox checked={!!tagsForFile?.find((t) => t.id === tag.id)} />,
leftItem: (
<div className="relative">
<div
className="block w-[15px] h-[15px] mr-0.5 border rounded-full"
style={{
backgroundColor: active
? tag.color || '#efefef'
: 'transparent' || '#efefef',
borderColor: tag.color || '#efefef'
}}
/>
</div>
),
onClick(e) {
e.preventDefault();
if (contextMenuObjectId != null)
assignTag({
tag_id: tag.id,
file_id: contextMenuObjectId,
unassign: active
});
}
};
}) || []
]
}
],
[
{
label: 'More actions...',
icon: Plus,
children: [
// [
// {
// label: 'Move to library',
// icon: FilePlus,
// children: [libraries?.map((library) => ({ label: library.config.name })) || []]
// },
// {
// label: 'Remove from library',
// icon: FileX
// }
// ],
[
{
label: 'Encrypt',
icon: LockSimple
},
{
label: 'Compress',
icon: Package
},
{
label: 'Convert to',
icon: ArrowBendUpRight,
children: [
[
{
label: 'PNG'
},
{
label: 'WebP'
}
]
]
}
// {
// label: 'Mint NFT',
// icon: TrashIcon
// }
],
[
{
label: 'Secure delete',
icon: TrashSimple
}
]
]
}
],
[
{
label: 'Delete',
icon: Trash,
danger: true
}
]
]}
>
<ExplorerContextMenu>
<div className="relative flex flex-col w-full bg-gray-650">
<TopBar />
<div className="relative flex flex-row w-full max-h-full">
<FileList data={props.data?.items || []} context={props.data.context} />
{showInspector && (
<VirtualizedList data={props.data?.items || []} context={props.data.context} />
{expStore.showInspector && (
<div className="min-w-[260px] max-w-[260px]">
{props.data.items[selectedRowIndex]?.id && (
{props.data.items[expStore.selectedRowIndex]?.id && (
<Inspector
key={props.data.items[selectedRowIndex].id}
data={props.data.items[selectedRowIndex]}
key={props.data.items[expStore.selectedRowIndex].id}
data={props.data.items[expStore.selectedRowIndex]}
/>
)}
</div>
)}
</div>
</div>
</WithContextMenu>
</ExplorerContextMenu>
</div>
);
}

View file

@ -0,0 +1,124 @@
import { useExplorerStore, useLibraryMutation, useLibraryQuery } from '@sd/client';
import { ContextMenu as CM } from '@sd/ui';
import {
ArrowBendUpRight,
FilePlus,
FileX,
LockSimple,
Package,
Plus,
Share,
TagSimple,
Trash,
TrashSimple
} from 'phosphor-react';
import { useSnapshot } from 'valtio';
const AssignTagMenuItems = (props: { objectId: number }) => {
const tags = useLibraryQuery(['tags.list'], { suspense: true });
const tagsForFile = useLibraryQuery(['tags.getForFile', props.objectId], { suspense: true });
const { mutate: assignTag } = useLibraryMutation('tags.assign');
return (
<>
{tags.data?.map((tag) => {
const active = !!tagsForFile.data?.find((t) => t.id === tag.id);
return (
<CM.Item
key={tag.id}
onClick={(e) => {
e.preventDefault();
if (props.objectId === null) return;
assignTag({
tag_id: tag.id,
file_id: props.objectId,
unassign: active
});
}}
>
<div
className="block w-[15px] h-[15px] mr-0.5 border rounded-full"
style={{
backgroundColor: active ? tag.color || '#efefef' : 'transparent' || '#efefef',
borderColor: tag.color || '#efefef'
}}
/>
<p>{tag.name}</p>
</CM.Item>
);
})}
</>
);
};
interface Props {
children: React.ReactNode;
}
export default function ExplorerContextMenu(props: Props) {
const store = useExplorerStore();
return (
<div className="relative">
<CM.ContextMenu trigger={props.children}>
<CM.Item label="Open" />
<CM.Item label="Open with..." />
<CM.Separator />
<CM.Item label="Quick view" />
<CM.Item label="Open in Finder" />
<CM.Separator />
<CM.Item label="Rename" />
<CM.Item label="Duplicate" />
<CM.Separator />
<CM.Item
label="Share"
icon={Share}
onClick={(e) => {
e.preventDefault();
navigator.share?.({
title: 'Spacedrive',
text: 'Check out this cool app',
url: 'https://spacedrive.com'
});
}}
/>
<CM.Separator />
{store.contextMenuObjectId && (
<CM.SubMenu label="Assign tag" icon={TagSimple}>
<AssignTagMenuItems objectId={store.contextMenuObjectId} />
</CM.SubMenu>
)}
<CM.SubMenu label="More actions..." icon={Plus}>
<CM.SubMenu label="Move to library" icon={FilePlus}>
{/* {libraries.map(library => <CM.Item key={library.id} label={library.config.name} />)} */}
<CM.Item label="Remove from library" icon={FileX} />
</CM.SubMenu>
<CM.Separator />
<CM.Item label="Encrypt" icon={LockSimple} />
<CM.Item label="Compress" icon={Package} />
<CM.SubMenu label="Convert to" icon={ArrowBendUpRight}>
<CM.Item label="PNG" />
<CM.Item label="WebP" />
</CM.SubMenu>
<CM.Item label="Secure delete" icon={TrashSimple} />
</CM.SubMenu>
<CM.Separator />
<CM.Item icon={Trash} label="Delete" variant="danger" />
</CM.ContextMenu>
</div>
);
}

View file

@ -1,31 +1,29 @@
import { ReactComponent as Folder } from '@sd/assets/svgs/folder.svg';
import { LocationContext, useExplorerStore } from '@sd/client';
import { ExplorerData, ExplorerItem, File, FilePath } from '@sd/core';
import { getExplorerStore, useExplorerStore } from '@sd/client';
import { ExplorerItem } from '@sd/core';
import clsx from 'clsx';
import React, { useContext } from 'react';
import { HTMLAttributes } from 'react';
import icons from '../../assets/icons';
import FileThumb from './FileThumb';
import { isObject, isPath } from './utils';
import { isObject } from './utils';
interface Props extends React.HTMLAttributes<HTMLDivElement> {
interface Props extends HTMLAttributes<HTMLDivElement> {
data: ExplorerItem;
selected: boolean;
size: number;
index: number;
}
export default function FileItem(props: Props) {
const { set } = useExplorerStore();
const size = props.size || 100;
function FileItem(props: Props) {
const store = useExplorerStore();
return (
<div
onContextMenu={(e) => {
const objectId = isObject(props.data) ? props.data.id : props.data.file?.id;
if (objectId != undefined) {
set({ contextMenuObjectId: objectId });
if (props.index != undefined) set({ selectedRowIndex: props.index });
getExplorerStore().contextMenuObjectId = objectId;
if (props.index != undefined) {
getExplorerStore().selectedRowIndex = props.index;
}
}
}}
draggable
@ -33,7 +31,7 @@ export default function FileItem(props: Props) {
className={clsx('inline-block w-[100px] mb-3', props.className)}
>
<div
style={{ width: size, height: size }}
style={{ width: store.gridItemSize, height: store.gridItemSize }}
className={clsx(
'border-2 border-transparent rounded-lg text-center mb-1 active:translate-y-[1px]',
{
@ -51,7 +49,7 @@ export default function FileItem(props: Props) {
'border-4 border-gray-250 rounded-sm shadow-md shadow-gray-750 max-h-full max-w-full overflow-hidden'
)}
data={props.data}
size={100}
size={store.gridItemSize}
/>
</div>
</div>
@ -71,3 +69,5 @@ export default function FileItem(props: Props) {
</div>
);
}
export default FileItem;

View file

@ -1,255 +0,0 @@
import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid';
import { LocationContext, useBridgeQuery, useExplorerStore, useLibraryQuery } from '@sd/client';
import { ExplorerContext, ExplorerItem, FilePath } from '@sd/core';
import clsx from 'clsx';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Virtuoso, VirtuosoGrid, VirtuosoHandle } from 'react-virtuoso';
import { useKey, useWindowSize } from 'rooks';
import styled from 'styled-components';
import FileItem from './FileItem';
import FileThumb from './FileThumb';
import { isPath } from './utils';
interface IColumn {
column: string;
key: string;
width: number;
}
// Function ensure no types are lost, but guarantees that they are Column[]
function ensureIsColumns<T extends IColumn[]>(data: T) {
return data;
}
const columns = ensureIsColumns([
{ column: 'Name', key: 'name', width: 280 } as const,
// { column: 'Size', key: 'size_in_bytes', width: 120 } as const,
{ column: 'Type', key: 'extension', width: 100 } as const
]);
type ColumnKey = typeof columns[number]['key'];
// these styled components are out of place, but are here to follow the virtuoso docs. could probably be translated to tailwind somehow, since the `components` prop only accepts a styled div, not a react component.
const GridContainer = styled.div`
display: flex;
margin-top: 60px;
margin-left: 10px;
width: 100%;
flex-wrap: wrap;
`;
const GridItemContainer = styled.div`
display: flex;
flex-wrap: wrap;
`;
interface Props {
context: ExplorerContext;
data: ExplorerItem[];
}
export const FileList: React.FC<Props> = (props) => {
const size = useWindowSize();
const tableContainer = useRef<null | HTMLDivElement>(null);
const VList = useRef<null | VirtuosoHandle>(null);
const { data: client } = useBridgeQuery(['getNode'], {
refetchOnWindowFocus: false
});
const { selectedRowIndex, set, layoutMode } = useExplorerStore();
const [goingUp, setGoingUp] = useState(false);
useEffect(() => {
if (selectedRowIndex === 0 && goingUp) {
VList.current?.scrollTo({ top: 0, behavior: 'smooth' });
}
if (selectedRowIndex !== -1 && typeof VList.current?.scrollIntoView === 'function') {
VList.current?.scrollIntoView({
index: goingUp ? selectedRowIndex - 1 : selectedRowIndex
});
}
}, [goingUp, selectedRowIndex]);
useKey('ArrowUp', (e) => {
e.preventDefault();
setGoingUp(true);
if (selectedRowIndex !== -1 && selectedRowIndex !== 0)
set({ selectedRowIndex: selectedRowIndex - 1 });
});
useKey('ArrowDown', (e) => {
e.preventDefault();
setGoingUp(false);
if (selectedRowIndex !== -1 && selectedRowIndex !== (props.data.length ?? 1) - 1)
set({ selectedRowIndex: selectedRowIndex + 1 });
});
const createRenderItem = (RenderItem: React.FC<RenderItemProps>) => {
return (index: number) => {
const row = props.data[index];
if (!row) return null;
return <RenderItem key={index} index={index} item={row} />;
};
};
const Header = () => (
<div>
{props.context.name && (
<h1 className="pt-20 pl-4 text-xl font-bold ">{props.context.name}</h1>
)}
<div className="table-head">
<div className="flex flex-row p-2 table-head-row">
{columns.map((col) => (
<div
key={col.key}
className="relative flex flex-row items-center pl-2 table-head-cell group"
style={{ width: col.width }}
>
<EllipsisHorizontalIcon className="absolute hidden w-5 h-5 -ml-5 cursor-move group-hover:block drag-handle opacity-10" />
<span className="text-sm font-medium text-gray-500">{col.column}</span>
</div>
))}
</div>
</div>
</div>
);
return (
<div ref={tableContainer} style={{ marginTop: -44 }} className="w-full pl-2 cursor-default ">
{layoutMode === 'grid' && (
<VirtuosoGrid
ref={VList}
overscan={5000}
components={{
Item: GridItemContainer,
List: GridContainer
}}
style={{ height: size.innerHeight ?? 600 }}
totalCount={props.data.length || 0}
itemContent={createRenderItem(RenderGridItem)}
className="w-full overflow-x-hidden outline-none explorer-scroll"
/>
)}
{layoutMode === 'list' && (
<Virtuoso
data={props.data} // this might be redundant, row data is retrieved by index in renderRow
ref={VList}
style={{ height: size.innerHeight ?? 600 }}
totalCount={props.data.length || 0}
itemContent={createRenderItem(RenderRow)}
components={{
Header,
Footer: () => <div className="w-full " />
}}
increaseViewportBy={{ top: 400, bottom: 200 }}
className="outline-none explorer-scroll"
/>
)}
</div>
);
};
interface RenderItemProps {
item: ExplorerItem;
index: number;
}
const RenderGridItem: React.FC<RenderItemProps> = ({ item, index }) => {
const { selectedRowIndex, set } = useExplorerStore();
const [_, setSearchParams] = useSearchParams();
return (
<FileItem
onDoubleClick={() => {
if (item.type === 'Path' && item.is_dir) {
setSearchParams({ path: item.materialized_path });
}
}}
index={index}
data={item}
selected={selectedRowIndex === index}
onClick={() => {
set({ selectedRowIndex: selectedRowIndex == index ? -1 : index });
}}
size={100}
/>
);
};
const RenderRow: React.FC<RenderItemProps> = ({ item, index }) => {
const { selectedRowIndex, set } = useExplorerStore();
const isActive = selectedRowIndex === index;
const [_, setSearchParams] = useSearchParams();
return useMemo(
() => (
<div
onClick={() => set({ selectedRowIndex: selectedRowIndex == index ? -1 : index })}
onDoubleClick={() => {
if (isPath(item) && item.is_dir) {
setSearchParams({ path: item.materialized_path });
}
}}
className={clsx(
'table-body-row mr-2 flex flex-row rounded-lg border-2',
isActive ? 'border-primary-500' : 'border-transparent',
index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
)}
>
{columns.map((col) => (
<div
key={col.key}
className="flex items-center px-4 py-2 pr-2 table-body-cell"
style={{ width: col.width }}
>
<RenderCell data={item} colKey={col.key} />
</div>
))}
</div>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[item.id, isActive]
);
};
const RenderCell: React.FC<{
colKey: ColumnKey;
data: ExplorerItem;
}> = ({ colKey, data }) => {
switch (colKey) {
case 'name':
return (
<div className="flex flex-row items-center overflow-hidden">
<div className="flex items-center justify-center w-6 h-6 mr-3 shrink-0">
<FileThumb data={data} size={0} />
</div>
{/* {colKey == 'name' &&
(() => {
switch (row.extension.toLowerCase()) {
case 'mov' || 'mp4':
return <FilmIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
default:
if (row.is_dir)
return <FolderIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
return <DocumentIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
}
})()} */}
<span className="text-xs truncate">{data[colKey]}</span>
</div>
);
// case 'size_in_bytes':
// return <span className="text-xs text-left">{byteSize(Number(value || 0))}</span>;
case 'extension':
return <span className="text-xs text-left">{data[colKey]}</span>;
// case 'meta_integrity_hash':
// return <span className="truncate">{value}</span>;
// case 'tags':
// return renderCellWithIcon(MusicNoteIcon);
default:
return <></>;
}
};

View file

@ -0,0 +1,95 @@
import { ExplorerItem } from '@sd/core';
import clsx from 'clsx';
import { HTMLAttributes } from 'react';
import FileThumb from './FileThumb';
interface Props extends HTMLAttributes<HTMLDivElement> {
data: ExplorerItem;
index: number;
selected: boolean;
}
function FileRow({ data, index, selected, ...props }: Props) {
return (
<div
{...props}
className={clsx(
'table-body-row mr-2 flex w-full flex-row rounded-lg border-2',
selected ? 'border-primary-500' : 'border-transparent',
index % 2 == 0 && 'bg-[#00000006] dark:bg-[#00000030]'
)}
>
{columns.map((col) => (
<div
key={col.key}
className="flex items-center px-4 py-2 pr-2 table-body-cell"
style={{ width: col.width }}
>
<RenderCell data={data} colKey={col.key} />
</div>
))}
</div>
);
}
const RenderCell: React.FC<{
colKey: ColumnKey;
data: ExplorerItem;
}> = ({ colKey, data }) => {
switch (colKey) {
case 'name':
return (
<div className="flex flex-row items-center overflow-hidden">
<div className="flex items-center justify-center w-6 h-6 mr-3 shrink-0">
<FileThumb data={data} size={0} />
</div>
{/* {colKey == 'name' &&
(() => {
switch (row.extension.toLowerCase()) {
case 'mov' || 'mp4':
return <FilmIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
default:
if (row.is_dir)
return <FolderIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
return <DocumentIcon className="flex-shrink-0 w-5 h-5 mr-3 text-gray-300" />;
}
})()} */}
<span className="text-xs truncate">{data[colKey]}</span>
</div>
);
// case 'size_in_bytes':
// return <span className="text-xs text-left">{byteSize(Number(value || 0))}</span>;
case 'extension':
return <span className="text-xs text-left">{data[colKey]}</span>;
// case 'meta_integrity_hash':
// return <span className="truncate">{value}</span>;
// case 'tags':
// return renderCellWithIcon(MusicNoteIcon);
default:
return <></>;
}
};
interface IColumn {
column: string;
key: string;
width: number;
}
// Function ensure no types are lost, but guarantees that they are Column[]
function ensureIsColumns<T extends IColumn[]>(data: T) {
return data;
}
const columns = ensureIsColumns([
{ column: 'Name', key: 'name', width: 280 } as const,
// { column: 'Size', key: 'size_in_bytes', width: 120 } as const,
{ column: 'Type', key: 'extension', width: 100 } as const
]);
type ColumnKey = typeof columns[number]['key'];
export default FileRow;

View file

@ -1,7 +1,7 @@
import { AppPropsContext, useExplorerStore } from '@sd/client';
import { useExplorerStore, usePlatform } from '@sd/client';
import { ExplorerItem } from '@sd/core';
import clsx from 'clsx';
import React, { useContext } from 'react';
import { useSnapshot } from 'valtio';
import icons from '../../assets/icons';
import { Folder } from '../icons/Folder';
@ -15,8 +15,8 @@ interface Props {
}
export default function FileThumb({ data, ...props }: Props) {
const appProps = useContext(AppPropsContext);
const { newThumbnails } = useExplorerStore();
const platform = usePlatform();
const store = useExplorerStore();
if (isPath(data) && data.is_dir) return <Folder size={props.size * 0.7} />;
@ -28,19 +28,15 @@ export default function FileThumb({ data, ...props }: Props) {
? data.has_thumbnail
: isPath(data)
? data.file?.has_thumbnail
: !!newThumbnails[cas_id];
: !!store.newThumbnails[cas_id];
const file_thumb_url =
has_thumbnail && appProps?.data_path
? appProps?.convertFileSrc(`${appProps.data_path}/thumbnails/${cas_id}.webp`)
: undefined;
if (file_thumb_url)
if (has_thumbnail)
return (
<img
// onLoad={}
style={props.style}
className={clsx('pointer-events-none z-90', props.className)}
src={file_thumb_url}
src={platform.getThumbnailUrlById(cas_id)}
/>
);
@ -83,6 +79,4 @@ export default function FileThumb({ data, ...props }: Props) {
</svg>
</div>
);
return null;
}

View file

@ -1,11 +1,11 @@
import { ShareIcon } from '@heroicons/react/24/solid';
import { useLibraryMutation, useLibraryQuery } from '@sd/client';
import { useLibraryQuery } from '@sd/client';
import { ExplorerContext, ExplorerItem, File, FilePath, Location } from '@sd/core';
import { Button, TextArea } from '@sd/ui';
import clsx from 'clsx';
import moment from 'moment';
import { Heart, Link } from 'phosphor-react';
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'phosphor-react';
import { useEffect, useState } from 'react';
import types from '../../constants/file-types.json';
import { Tooltip } from '../tooltip/Tooltip';
@ -26,7 +26,19 @@ export const Inspector = (props: Props) => {
const objectData = isObject(props.data) ? props.data : props.data.file;
const { data: tags } = useLibraryQuery(['tags.getForFile', objectData?.id || -1]);
// this prevents the inspector from fetching data when the user is navigating quickly
const [readyToFetch, setReadyToFetch] = useState(false);
useEffect(() => {
const timeout = setTimeout(() => {
setReadyToFetch(true);
}, 350);
return () => clearTimeout(timeout);
}, [props.data.id]);
// this is causing LAG
const { data: tags } = useLibraryQuery(['tags.getForFile', objectData?.id || -1], {
enabled: readyToFetch
});
return (
<div className="p-2 pr-1 overflow-x-hidden custom-scroll inspector-scroll pb-[55px]">

View file

@ -0,0 +1,193 @@
import { ExplorerLayoutMode, getExplorerStore, useExplorerStore } from '@sd/client';
import { ExplorerContext, ExplorerItem, FilePath } from '@sd/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useKey, useOnWindowResize, useWindowSize } from 'rooks';
import { useSnapshot } from 'valtio';
import FileItem from './FileItem';
import FileRow from './FileRow';
import { isPath } from './utils';
const TOP_BAR_HEIGHT = 50;
const GRID_TEXT_AREA_HEIGHT = 25;
interface Props {
context: ExplorerContext;
data: ExplorerItem[];
}
export const VirtualizedList: React.FC<Props> = ({ data, context }) => {
const scrollRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [goingUp, setGoingUp] = useState(false);
const [width, setWidth] = useState(0);
const store = useExplorerStore();
function handleWindowResize() {
// so the virtualizer can render the correct number of columns
setWidth(innerRef.current?.offsetWidth || 0);
}
useOnWindowResize(handleWindowResize);
useLayoutEffect(() => handleWindowResize(), []);
// sizing calculations
const amountOfColumns = Math.floor(width / store.gridItemSize) || 8,
amountOfRows =
store.layoutMode === 'grid' ? Math.ceil(data.length / amountOfColumns) : data.length,
itemSize =
store.layoutMode === 'grid' ? store.gridItemSize + GRID_TEXT_AREA_HEIGHT : store.listItemSize;
const rowVirtualizer = useVirtualizer({
count: amountOfRows,
getScrollElement: () => scrollRef.current,
overscan: 500,
estimateSize: () => itemSize,
measureElement: (index) => itemSize
});
// TODO: Make scroll adjustment work with both list and grid layout, currently top bar offset disrupts positioning of list, and grid just doesn't work
// useEffect(() => {
// if (selectedRowIndex === 0 && goingUp) rowVirtualizer.scrollToIndex(0, { smoothScroll: false });
// if (selectedRowIndex !== -1)
// rowVirtualizer.scrollToIndex(goingUp ? selectedRowIndex - 1 : selectedRowIndex, {
// smoothScroll: false
// });
// }, [goingUp, selectedRowIndex, rowVirtualizer]);
useKey('ArrowUp', (e) => {
e.preventDefault();
setGoingUp(true);
if (store.selectedRowIndex !== -1 && store.selectedRowIndex !== 0)
getExplorerStore().selectedRowIndex = store.selectedRowIndex - 1;
});
useKey('ArrowDown', (e) => {
e.preventDefault();
setGoingUp(false);
if (store.selectedRowIndex !== -1 && store.selectedRowIndex !== (data.length ?? 1) - 1)
getExplorerStore().selectedRowIndex = store.selectedRowIndex + 1;
});
// const Header = () => (
// <div>
// {props.context.name && (
// <h1 className="pt-20 pl-4 text-xl font-bold ">{props.context.name}</h1>
// )}
// <div className="table-head">
// <div className="flex flex-row p-2 table-head-row">
// {columns.map((col) => (
// <div
// key={col.key}
// className="relative flex flex-row items-center pl-2 table-head-cell group"
// style={{ width: col.width }}
// >
// <EllipsisHorizontalIcon className="absolute hidden w-5 h-5 -ml-5 cursor-move group-hover:block drag-handle opacity-10" />
// <span className="text-sm font-medium text-gray-500">{col.column}</span>
// </div>
// ))}
// </div>
// </div>
// </div>
// );
return (
<div style={{ marginTop: -TOP_BAR_HEIGHT }} className="w-full pl-2 cursor-default">
<div ref={scrollRef} className="h-screen custom-scroll explorer-scroll">
<div
ref={innerRef}
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
marginTop: `${TOP_BAR_HEIGHT}px`
}}
className="relative w-full"
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`
}}
className="absolute top-0 left-0 flex w-full"
key={virtualRow.key}
>
{store.layoutMode === 'list' ? (
<WrappedItem
kind="list"
isSelected={store.selectedRowIndex === virtualRow.index}
index={virtualRow.index}
item={data[virtualRow.index]}
/>
) : (
[...Array(amountOfColumns)].map((_, i) => {
const index = virtualRow.index * amountOfColumns + i;
const item = data[index];
return (
<div key={index} className="w-32 h-32">
<div className="flex">
{item && (
<WrappedItem
kind="grid"
isSelected={store.selectedRowIndex === index}
index={index}
item={item}
/>
)}
</div>
</div>
);
})
)}
</div>
))}
</div>
</div>
</div>
);
};
interface WrappedItemProps {
item: ExplorerItem;
index: number;
isSelected: boolean;
kind: ExplorerLayoutMode;
}
// Wrap either list item or grid item with click logic as it is the same for both
const WrappedItem: React.FC<WrappedItemProps> = memo(({ item, index, isSelected, kind }) => {
const [_, setSearchParams] = useSearchParams();
const onDoubleClick = useCallback(() => {
if (isPath(item) && item.is_dir) setSearchParams({ path: item.materialized_path });
}, [item, setSearchParams]);
const onClick = useCallback(() => {
getExplorerStore().selectedRowIndex = isSelected ? -1 : index;
}, [isSelected, index]);
if (kind === 'list') {
return (
<FileRow
data={item}
index={index}
onClick={onClick}
onDoubleClick={onDoubleClick}
selected={isSelected}
/>
);
}
return (
<FileItem
data={item}
index={index}
onClick={onClick}
onDoubleClick={onDoubleClick}
selected={isSelected}
/>
);
});

View file

@ -1,3 +1 @@
import React from 'react';
export const Divider = () => <div className="w-full my-1 h-[1px] bg-gray-100 dark:bg-gray-550" />;

View file

@ -2,7 +2,7 @@ import { useLibraryMutation } from '@sd/client';
import { File } from '@sd/core';
import { Button } from '@sd/ui';
import { Heart } from 'phosphor-react';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
interface Props {
data: File;

View file

@ -1,5 +1,3 @@
import React from 'react';
interface MetaItemProps {
title?: string;
value: string | React.ReactNode;

View file

@ -3,7 +3,7 @@ import { useLibraryMutation } from '@sd/client';
import { File } from '@sd/core';
import { TextArea } from '@sd/ui';
import { debounce } from 'lodash';
import React, { useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import { Divider } from './Divider';
import { MetaItem } from './MetaItem';

View file

@ -1,6 +1,5 @@
import folderWhiteSvg from '@sd/assets/svgs/folder-white.svg';
import folderSvg from '@sd/assets/svgs/folder.svg';
import React from 'react';
interface FolderProps {
/**

View file

@ -1,5 +1,4 @@
import clsx from 'clsx';
import React from 'react';
import { DefaultProps } from '../primitive/types';

View file

@ -0,0 +1,101 @@
import { EyeIcon, FolderIcon, PhotoIcon, XMarkIcon } from '@heroicons/react/24/outline';
import { useLibraryQuery } from '@sd/client';
import { JobReport } from '@sd/core';
import { Button } from '@sd/ui';
import clsx from 'clsx';
import { formatDistanceToNow, formatDuration } from 'date-fns';
import { ArrowsClockwise } from 'phosphor-react';
import { Tooltip } from '../tooltip/Tooltip';
interface JobNiceData {
name: string;
icon: React.FC<React.ComponentProps<'svg'>>;
}
const NiceData: Record<string, JobNiceData> = {
indexer: {
name: 'Indexed location',
icon: FolderIcon
},
thumbnailer: {
name: 'Generated thumbnails',
icon: PhotoIcon
},
file_identifier: {
name: 'Identified unique files',
icon: EyeIcon
}
};
const StatusColors: Record<JobReport['status'], string> = {
Running: 'text-blue-500',
Failed: 'text-red-500',
Completed: 'text-green-500',
Queued: 'text-yellow-500',
Canceled: 'text-gray-500',
Paused: 'text-gray-500'
};
function elapsed(seconds: number) {
return new Date(seconds * 1000).toUTCString().match(/(\d\d:\d\d:\d\d)/)?.[0];
}
export function JobsManager() {
const jobs = useLibraryQuery(['jobs.getHistory']);
return (
<div className="h-full">
{/* <div className="z-10 flex flex-row w-full h-10 bg-gray-500 border-b border-gray-700 bg-opacity-30"></div> */}
<div className="h-full mr-1 overflow-x-hidden custom-scroll inspector-scroll">
<div className="py-1 pl-2">
<div className="fixed flex items-center h-10 ">
<h3 className="mt-1.5 ml-2 text-md font-medium opacity-40">Recent Jobs</h3>
</div>
<div className="h-10"></div>
{jobs.data?.map((job) => {
const color = StatusColors[job.status];
const niceData = NiceData[job.name];
return (
<div
className="flex items-center px-2 py-2 border-b border-gray-500 bg-opacity-60"
key={job.id}
>
<Tooltip label={job.status}>
<niceData.icon className={clsx('w-5 mr-3', color)} />
</Tooltip>
<div className="flex flex-col">
<span className="flex mt-0.5 items-center font-semibold">{niceData.name}</span>
<div className="flex items-center">
<span className="text-xs opacity-60">
{job.status === 'Failed' ? 'Failed after' : 'Took'}{' '}
{job.seconds_elapsed
? formatDuration({ seconds: job.seconds_elapsed })
: 'less than a second'}
</span>
<span className="mx-1 opacity-30">&#8226;</span>
<span className="text-xs opacity-60">
{formatDistanceToNow(new Date(job.date_created))} ago
</span>
</div>
<span className="text-xs opacity-60">{job.data}</span>
</div>
<div className="flex-grow" />
<div className="flex space-x-2">
{job.status === 'Failed' && (
<Button className="!p-0 w-7 h-7 flex items-center" variant="gray">
<ArrowsClockwise className="w-4" />
</Button>
)}
<Button className="!p-0 w-7 h-7 flex items-center" variant="gray">
<XMarkIcon className="w-4" />
</Button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
import { Transition } from '@headlessui/react';
import { useLibraryQuery } from '@sd/client';
import clsx from 'clsx';
import React, { DetailedHTMLProps, HTMLAttributes } from 'react';
import { DetailedHTMLProps, HTMLAttributes } from 'react';
import ProgressBar from '../primitive/ProgressBar';
@ -19,7 +19,7 @@ const MiddleTruncatedText = ({
const endWidth = fontFaceScaleFactor * 4;
return (
<div className="whitespace-nowrap overflow-hidden w-full">
<div className="w-full overflow-hidden whitespace-nowrap">
<span
{...props}
style={{
@ -66,7 +66,10 @@ export default function RunningJobsWidget() {
leaveFrom="translate-y-0"
leaveTo="translate-y-24"
>
<div key={job.id} className="flex flex-col px-2 pt-1.5 pb-2 bg-gray-700 rounded">
<div
key={job.id}
className="flex flex-col px-2 pt-1.5 pb-2 border bg-gray-600 bg-opacity-50 border-gray-500 rounded"
>
{/* <span className="mb-0.5 text-tiny font-bold text-gray-400">{job.status} Job</span> */}
<MiddleTruncatedText className="mb-1.5 text-gray-450 text-tiny">
{job.message}

View file

@ -1,5 +1,5 @@
import clsx from 'clsx';
import React, { ReactNode } from 'react';
import { ReactNode } from 'react';
export default function Card(props: { children: ReactNode; className?: string }) {
return (

View file

@ -1,7 +1,7 @@
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Button } from '@sd/ui';
import clsx from 'clsx';
import React, { ReactNode } from 'react';
import { ReactNode } from 'react';
import Loader from '../primitive/Loader';
@ -24,34 +24,41 @@ export default function Dialog(props: DialogProps) {
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed top-0 dialog-overlay bottom-0 left-0 right-0 z-50 grid overflow-y-auto bg-black bg-opacity-50 rounded-xl place-items-center m-[1px]">
<DialogPrimitive.Content className="min-w-[300px] max-w-[400px] dialog-content rounded-md bg-gray-650 text-white border border-gray-550 shadow-deep">
<div className="p-5">
<DialogPrimitive.Title className="mb-2 font-bold">
{props.title}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-gray-300">
{props.description}
</DialogPrimitive.Description>
{props.children}
</div>
<div className="flex flex-row justify-end px-3 py-3 space-x-2 bg-gray-600 border-t border-gray-550">
{props.loading && <Loader />}
<div className="flex-grow" />
<DialogPrimitive.Close asChild>
<Button loading={props.loading} disabled={props.loading} size="sm" variant="gray">
Close
<form
onSubmit={(e) => {
if (props.ctaAction) props.ctaAction();
e.preventDefault();
}}
>
<div className="p-5">
<DialogPrimitive.Title className="mb-2 font-bold">
{props.title}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-gray-300">
{props.description}
</DialogPrimitive.Description>
{props.children}
</div>
<div className="flex flex-row justify-end px-3 py-3 space-x-2 bg-gray-600 border-t border-gray-550">
{props.loading && <Loader />}
<div className="flex-grow" />
<DialogPrimitive.Close asChild>
<Button loading={props.loading} disabled={props.loading} size="sm" variant="gray">
Close
</Button>
</DialogPrimitive.Close>
<Button
type="submit"
size="sm"
loading={props.loading}
disabled={props.loading || props.submitDisabled}
variant={props.ctaDanger ? 'colored' : 'primary'}
className={clsx(props.ctaDanger && 'bg-red-500 border-red-500')}
>
{props.ctaLabel}
</Button>
</DialogPrimitive.Close>
<Button
onClick={props.ctaAction}
size="sm"
loading={props.loading}
disabled={props.loading || props.submitDisabled}
variant={props.ctaDanger ? 'colored' : 'primary'}
className={clsx(props.ctaDanger && 'bg-red-500 border-red-500')}
>
{props.ctaLabel}
</Button>
</div>
</div>
</form>
</DialogPrimitive.Content>
</DialogPrimitive.Overlay>
</DialogPrimitive.Portal>

View file

@ -1,17 +0,0 @@
import { ContextMenu, ContextMenuProps, Root, Trigger } from '@sd/ui';
import React, { ComponentProps } from 'react';
export const WithContextMenu: React.FC<{
menu: ContextMenuProps['items'];
children: ComponentProps<typeof Trigger>['children'];
}> = (props) => {
const { menu: sections = [], children } = props;
return (
<Root>
<Trigger>{children}</Trigger>
<ContextMenu items={sections} />
</Root>
);
};

View file

@ -2,7 +2,6 @@ import { Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/solid';
import { Button } from '@sd/ui';
import clsx from 'clsx';
import React from 'react';
import { useNavigate } from 'react-router-dom';
export interface ModalProps {

View file

@ -1,25 +1,18 @@
import CreateLibraryDialog from '../dialog/CreateLibraryDialog';
import { Folder } from '../icons/Folder';
import RunningJobsWidget from '../jobs/RunningJobsWidget';
import { MacTrafficLights } from '../os/TrafficLights';
import { DefaultProps } from '../primitive/types';
import { LockClosedIcon, PhotoIcon } from '@heroicons/react/24/outline';
import { CogIcon, PlusIcon } from '@heroicons/react/24/solid';
import {
AppPropsContext,
useCurrentLibrary,
useLibraryMutation,
useLibraryQuery,
useLibraryStore
} from '@sd/client';
import { CogIcon, LockClosedIcon, PhotoIcon } from '@heroicons/react/24/outline';
import { PlusIcon } from '@heroicons/react/24/solid';
import { useCurrentLibrary, useLibraryMutation, useLibraryQuery, usePlatform } from '@sd/client';
import { LocationCreateArgs } from '@sd/core';
import { Button, Dropdown } from '@sd/ui';
import { Button, Dropdown, OverlayPanel } from '@sd/ui';
import clsx from 'clsx';
import { CirclesFour, Planet, WaveTriangle } from 'phosphor-react';
import React, { useContext, useEffect } from 'react';
import { CheckCircle, CirclesFour, Planet, WaveTriangle } from 'phosphor-react';
import { NavLink, NavLinkProps, useNavigate } from 'react-router-dom';
type SidebarProps = DefaultProps;
import { useOperatingSystem } from '../../hooks/useOperatingSystem';
import CreateLibraryDialog from '../dialog/CreateLibraryDialog';
import { Folder } from '../icons/Folder';
import { JobsManager } from '../jobs/JobManager';
import RunningJobsWidget from '../jobs/RunningJobsWidget';
import { MacTrafficLights } from '../os/TrafficLights';
export const SidebarLink = (props: NavLinkProps & { children: React.ReactNode }) => (
<NavLink {...props}>
@ -47,145 +40,40 @@ const Heading: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="mt-5 mb-1 ml-1 text-xs font-semibold text-gray-300">{children}</div>
);
export const MacWindowControlsSpace: React.FC<{
children?: React.ReactNode;
}> = (props) => {
const { children } = props;
return (
<div data-tauri-drag-region className="flex-shrink-0 h-7">
{children}
</div>
);
};
export function MacWindowControls() {
const appProps = useContext(AppPropsContext);
return (
<MacWindowControlsSpace>
<MacTrafficLights
onClose={appProps?.onClose}
onFullscreen={appProps?.onFullscreen}
onMinimize={appProps?.onMinimize}
className="z-50 absolute top-[13px] left-[13px]"
/>
</MacWindowControlsSpace>
);
}
// cute little helper to decrease code clutter
const macOnly = (platform: string | undefined, classnames: string) =>
platform === 'macOS' ? classnames : '';
export const Sidebar: React.FC<SidebarProps> = (props) => {
const navigate = useNavigate();
const appProps = useContext(AppPropsContext);
const { data: locations } = useLibraryQuery(['locations.list']);
function WindowControls() {
const { platform } = usePlatform();
// initialize libraries
const { init: initLibraries, switchLibrary } = useLibraryStore();
const { currentLibrary, libraries, currentLibraryUuid } = useCurrentLibrary();
const showControls = window.location.search.includes('showControls');
if (platform === 'tauri' || showControls) {
return (
<div data-tauri-drag-region className="flex-shrink-0 h-7">
{/* We do not provide the onClick handlers for 'MacTrafficLights' because this is only used in demo mode */}
{showControls && <MacTrafficLights className="z-50 absolute top-[13px] left-[13px]" />}
</div>
);
}
useEffect(() => {
if (libraries && !currentLibraryUuid) initLibraries(libraries);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [libraries, currentLibraryUuid]);
return null;
}
function LibraryScopedSection() {
const os = useOperatingSystem();
const platform = usePlatform();
const { data: locations } = useLibraryQuery(['locations.list'], { keepPreviousData: true });
const { data: tags } = useLibraryQuery(['tags.list'], { keepPreviousData: true });
const { mutate: createLocation } = useLibraryMutation('locations.create');
const { data: tags } = useLibraryQuery(['tags.list']);
return (
<div
className={clsx(
'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-750',
{
'dark:!bg-opacity-40': appProps?.platform === 'macOS'
}
)}
>
{appProps?.platform === 'browser' && window.location.search.includes('showControls') ? (
<MacWindowControls />
) : null}
{appProps?.platform === 'macOS' ? <MacWindowControlsSpace /> : null}
<Dropdown
buttonProps={{
justifyLeft: true,
className: clsx(
`flex w-full text-left max-w-full mb-1 mt-1 -mr-0.5 shadow-xs rounded
!bg-gray-50
border-gray-150
hover:!bg-gray-1000
dark:!bg-gray-500
dark:hover:!bg-gray-500
dark:!border-gray-550
dark:hover:!border-gray-500
`,
appProps?.platform === 'macOS' &&
'dark:!bg-opacity-40 dark:hover:!bg-opacity-70 dark:!border-[#333949] dark:hover:!border-[#394052]'
),
variant: 'gray'
}}
// to support the transparent sidebar on macOS we use slightly adjusted styles
itemsClassName={macOnly(appProps?.platform, 'dark:bg-gray-800 dark:divide-gray-600')}
itemButtonClassName={macOnly(
appProps?.platform,
'dark:hover:bg-gray-550 dark:hover:bg-opacity-50'
)}
// this shouldn't default to "My Library", it is only this way for landing demo
// TODO: implement demo mode for the sidebar and show loading indicator instead of "My Library"
buttonText={currentLibrary?.config.name || ' '}
items={[
libraries?.map((library) => ({
name: library.config.name,
selected: library.uuid === currentLibraryUuid,
onPress: () => switchLibrary(library.uuid)
})) || [],
[
{
name: 'Library Settings',
icon: CogIcon,
onPress: () => navigate('settings/library')
},
{
name: 'Add Library',
icon: PlusIcon,
wrapItemComponent: CreateLibraryDialog
},
{
name: 'Lock',
icon: LockClosedIcon,
onPress: () => {
alert('todo');
}
}
]
]}
/>
<div className="pt-1">
<SidebarLink to="/overview">
<Icon component={Planet} />
Overview
</SidebarLink>
<SidebarLink to="content">
<Icon component={CirclesFour} />
Spaces
</SidebarLink>
<SidebarLink to="photos">
<Icon component={PhotoIcon} />
Photos
</SidebarLink>
</div>
<>
<div>
<Heading>Locations</Heading>
{locations?.map((location, index) => {
{locations?.map((location) => {
return (
<div key={index} className="flex flex-row items-center">
<div key={location.id} className="flex flex-row items-center">
<NavLink
className="relative w-full group"
to={{
@ -217,8 +105,13 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
{(locations?.length || 0) < 1 && (
<button
onClick={() => {
appProps?.openDialog({ directory: true }).then((result) => {
console.log(result);
if (!platform.openFilePickerDialog) {
// TODO: Support opening locations on web
alert('Opening a dialogue is not supported on this platform!');
return;
}
platform.openFilePickerDialog().then((result) => {
// TODO: Pass indexer rules ids to create location
if (result)
createLocation({
@ -229,7 +122,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
}}
className={clsx(
'w-full px-2 py-1.5 mt-1 text-xs font-bold text-center text-gray-400 border border-dashed rounded border-transparent cursor-normal border-gray-350 transition',
appProps?.platform === 'macOS'
os === 'macOS'
? 'dark:text-gray-450 dark:border-gray-450 hover:dark:border-gray-400 dark:border-opacity-60'
: 'dark:text-gray-450 dark:border-gray-550 hover:dark:border-gray-500'
)}
@ -256,21 +149,122 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
) : (
<></>
)}
</>
);
}
export function Sidebar() {
const navigate = useNavigate();
const os = useOperatingSystem();
const { library, libraries, isLoading: isLoadingLibraries, switchLibrary } = useCurrentLibrary();
return (
<div
className={clsx(
'flex flex-col flex-grow-0 flex-shrink-0 w-48 min-h-full px-2.5 overflow-x-hidden overflow-y-scroll border-r border-gray-100 no-scrollbar bg-gray-50 dark:bg-gray-850 dark:border-gray-750',
macOnly(os, 'dark:!bg-opacity-40')
)}
>
<WindowControls />
<Dropdown
buttonProps={{
justifyLeft: true,
className: clsx(
`flex w-full text-left max-w-full mb-1 mt-1 -mr-0.5 shadow-xs rounded !bg-gray-50 border-gray-150 hover:!bg-gray-1000 dark:!bg-gray-500 dark:hover:!bg-gray-500 dark:!border-gray-550 dark:hover:!border-gray-500`,
macOnly(
os,
'dark:!bg-opacity-40 dark:hover:!bg-opacity-70 dark:!border-[#333949] dark:hover:!border-[#394052]'
)
),
variant: 'gray'
}}
// to support the transparent sidebar on macOS we use slightly adjusted styles
itemsClassName={macOnly(os, 'dark:bg-gray-800 dark:divide-gray-600')}
itemButtonClassName={macOnly(os, 'dark:hover:bg-gray-550 dark:hover:bg-opacity-50')}
// this shouldn't default to "My Library", it is only this way for landing demo
buttonText={isLoadingLibraries ? 'Loading...' : library ? library.config.name : ' '}
buttonTextClassName={library === null || isLoadingLibraries ? 'text-gray-300' : undefined}
items={[
libraries?.map((library) => ({
name: library.config.name,
selected: library.uuid === library?.uuid,
onPress: () => switchLibrary(library.uuid)
})) || [],
[
{
name: 'Library Settings',
icon: CogIcon,
onPress: () => navigate('settings/library')
},
{
name: 'Add Library',
icon: PlusIcon,
wrapItemComponent: CreateLibraryDialog
},
{
name: 'Lock',
icon: LockClosedIcon,
disabled: true,
onPress: () => {
alert('TODO: Not implemented yet!');
}
}
]
]}
/>
<div className="pt-1">
<SidebarLink to="/overview">
<Icon component={Planet} />
Overview
</SidebarLink>
<SidebarLink to="content">
<Icon component={CirclesFour} />
Spaces
</SidebarLink>
<SidebarLink to="photos">
<Icon component={PhotoIcon} />
Photos
</SidebarLink>
</div>
{library && <LibraryScopedSection />}
<div className="flex-grow" />
<RunningJobsWidget />
<div className="mb-2">
{library && <RunningJobsWidget />}
<div className="mt-2 mb-2">
<NavLink to="/settings/general">
{({ isActive }) => (
<Button
noPadding
variant={isActive ? 'default' : 'default'}
className={clsx('px-[4px] mb-1')}
variant={'default'}
className={clsx('px-[4px] hover:!bg-opacity-20 mb-1')}
>
<CogIcon className="w-5 h-5" />
</Button>
)}
</NavLink>
<OverlayPanel
className="focus:outline-none"
disabled={!library}
trigger={
<Button
noPadding
className={clsx(
'px-[4px] !outline-none hover:!bg-opacity-20 disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
<CheckCircle className="w-5 h-5" />
</Button>
}
>
<div className="block w-[500px] h-96">
<JobsManager />
</div>
</OverlayPanel>
</div>
</div>
);
};
}

View file

@ -1,5 +1,10 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import { AppProps, useAppProps, useExplorerStore, useLibraryMutation } from '@sd/client';
import {
OperatingSystem,
getExplorerStore,
useExplorerStore,
useLibraryMutation
} from '@sd/client';
import { Dropdown } from '@sd/ui';
import clsx from 'clsx';
import {
@ -11,22 +16,16 @@ import {
SidebarSimple,
SquaresFour
} from 'phosphor-react';
import React, { DetailedHTMLProps, HTMLAttributes } from 'react';
import { DetailedHTMLProps, HTMLAttributes, forwardRef, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom';
import { useOperatingSystem } from '../../hooks/useOperatingSystem';
import { KeybindEvent } from '../../util/keybind';
import { Shortcut } from '../primitive/Shortcut';
import { DefaultProps } from '../primitive/types';
import { Tooltip } from '../tooltip/Tooltip';
// useful for determining whether to show command or ctrl in keybinds
const isPlatformMac = (appProps: AppProps | null) =>
// running on macOS desktop app
appProps?.platform === 'macOS' ||
// running in browser on macOS
navigator.platform.startsWith('Mac');
export type TopBarProps = DefaultProps;
export interface TopBarButtonProps
extends DetailedHTMLProps<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> {
@ -65,9 +64,7 @@ const TopBarButton: React.FC<TopBarButtonProps> = ({
);
};
const SearchBar = React.forwardRef<HTMLInputElement, DefaultProps>((props, forwardedRef) => {
const appProps = useAppProps();
const SearchBar = forwardRef<HTMLInputElement, DefaultProps>((props, forwardedRef) => {
const {
register,
handleSubmit,
@ -82,6 +79,9 @@ const SearchBar = React.forwardRef<HTMLInputElement, DefaultProps>((props, forwa
}
});
const platform = useOperatingSystem(false);
const os = useOperatingSystem(true);
return (
<form onSubmit={handleSubmit(() => null)} className="relative flex h-7">
<input
@ -95,15 +95,16 @@ const SearchBar = React.forwardRef<HTMLInputElement, DefaultProps>((props, forwa
className="peer w-32 h-[30px] focus:w-52 text-sm p-3 rounded-lg outline-none focus:ring-2 placeholder-gray-400 dark:placeholder-gray-450 bg-[#F6F2F6] border border-gray-50 shadow-md dark:bg-gray-600 dark:border-gray-550 focus:ring-gray-100 dark:focus:ring-gray-550 dark:focus:bg-gray-800 transition-all"
{...searchField}
/>
<div
className={clsx(
'space-x-1 absolute top-[2px] right-1 peer-focus:invisible pointer-events-none',
isDirty && 'hidden'
)}
>
{appProps?.platform === 'browser' ? (
{platform === 'browser' ? (
<Shortcut chars="/" aria-label={'Press slash to focus search bar'} />
) : isPlatformMac(appProps) ? (
) : os === 'macOS' ? (
<Shortcut chars="⌘F" aria-label={'Press Command-F to focus search bar'} />
) : (
<Shortcut chars="CTRL+F" aria-label={'Press CTRL-F to focus search bar'} />
@ -115,21 +116,22 @@ const SearchBar = React.forwardRef<HTMLInputElement, DefaultProps>((props, forwa
});
export const TopBar: React.FC<TopBarProps> = (props) => {
const appProps = useAppProps();
const platform = useOperatingSystem(false);
const os = useOperatingSystem(true);
const { layoutMode, set, locationId, showInspector } = useExplorerStore();
const store = useExplorerStore();
const { mutate: generateThumbsForLocation } = useLibraryMutation(
'jobs.generateThumbsForLocation',
{
onMutate: (data) => {
console.log('GenerateThumbsForLocation', data);
// console.log('GenerateThumbsForLocation', data);
}
}
);
const { mutate: identifyUniqueFiles } = useLibraryMutation('jobs.identifyUniqueFiles', {
onMutate: (data) => {
console.log('IdentifyUniqueFiles', data);
// console.log('IdentifyUniqueFiles', data);
},
onError: (error) => {
console.error('IdentifyUniqueFiles', error);
@ -138,7 +140,8 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
const navigate = useNavigate();
const searchBarRef = React.useRef<HTMLInputElement>(null);
//create function to focus on search box when cmd+k is pressed
const searchRef = useRef<HTMLInputElement>(null);
const focusSearchBar = (bar: HTMLInputElement, e?: Event): boolean => {
bar.focus();
@ -147,8 +150,8 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
return false;
};
React.useEffect(() => {
const searchBar = searchBarRef.current;
useEffect(() => {
const searchBar = searchRef.current;
if (searchBar === null || !searchBar) return;
@ -165,9 +168,9 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
return;
}
const isBrowser = appProps?.platform === 'browser';
const isBrowser = platform === 'browser';
// use cmd on macOS and ctrl on Windows
const hasModifier = isBrowser && isPlatformMac(appProps) ? e.metaKey : e.ctrlKey;
const hasModifier = os === 'macOS' ? e.metaKey : e.ctrlKey;
if (
// allow slash on all platforms
@ -192,7 +195,7 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
document.removeEventListener('keydown', handleDOMKeydown);
document.removeEventListener('keybindexec', handleKeybindAction);
};
}, [appProps]);
}, [os, platform]);
return (
<>
@ -221,22 +224,23 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
<TopBarButton
group
left
active={layoutMode === 'list'}
active={store.layoutMode === 'list'}
icon={Rows}
onClick={() => set({ layoutMode: 'list' })}
onClick={() => (getExplorerStore().layoutMode = 'list')}
/>
</Tooltip>
<Tooltip label="Grid view">
<TopBarButton
group
right
active={layoutMode === 'grid'}
active={store.layoutMode === 'grid'}
icon={SquaresFour}
onClick={() => set({ layoutMode: 'grid' })}
onClick={() => (getExplorerStore().layoutMode = 'grid')}
/>
</Tooltip>
</div>
<SearchBar ref={searchBarRef} />
<SearchBar ref={searchRef} />
<div className="flex mx-8 space-x-2">
<Tooltip label="Major Key Alert">
@ -257,8 +261,8 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
</div>
<div className="flex mr-3 space-x-2">
<TopBarButton
active={showInspector}
onClick={() => set({ showInspector: !showInspector })}
active={store.showInspector}
onClick={() => (getExplorerStore().showInspector = !store.showInspector)}
className="my-2"
icon={SidebarSimple}
/>
@ -271,12 +275,14 @@ export const TopBar: React.FC<TopBarProps> = (props) => {
name: 'Generate Thumbs',
icon: ArrowsClockwise,
onPress: () =>
locationId && generateThumbsForLocation({ id: locationId, path: '' })
store.locationId &&
generateThumbsForLocation({ id: store.locationId, path: '' })
},
{
name: 'Identify Unique',
icon: ArrowsClockwise,
onPress: () => locationId && identifyUniqueFiles({ id: locationId, path: '' })
onPress: () =>
store.locationId && identifyUniqueFiles({ id: store.locationId, path: '' })
}
]
]}

Some files were not shown because too many files have changed in this diff Show more