diff --git a/core/src/api/locations.rs b/core/src/api/locations.rs index b75b3a57910d..9eefd9f29f0c 100644 --- a/core/src/api/locations.rs +++ b/core/src/api/locations.rs @@ -208,11 +208,13 @@ pub(crate) fn mount() -> AlphaRouter { R.with2(library()) .mutation(|(node, library), args: LocationCreateArgs| async move { if let Some(location) = args.create(&node, &library).await? { + let id = Some(location.id); scan_location(&node, &library, location).await?; invalidate_query!(library, "locations.list"); + Ok(id) + } else { + Ok(None) } - - Ok(()) }) }) .procedure("update", { @@ -244,10 +246,13 @@ pub(crate) fn mount() -> AlphaRouter { R.with2(library()) .mutation(|(node, library), args: LocationCreateArgs| async move { if let Some(location) = args.add_library(&node, &library).await? { + let id = location.id; scan_location(&node, &library, location).await?; invalidate_query!(library, "locations.list"); + Ok(Some(id)) + } else { + Ok(None) } - Ok(()) }) }) .procedure("fullRescan", { diff --git a/core/src/location/mod.rs b/core/src/location/mod.rs index ade87709bcf7..cd7fa9dc8304 100644 --- a/core/src/location/mod.rs +++ b/core/src/location/mod.rs @@ -9,7 +9,7 @@ use crate::{ }, prisma::{file_path, indexer_rules_in_location, location, PrismaClient}, util::{ - db::maybe_missing, + db::{maybe_missing, MissingFieldError}, error::{FileIOError, NonUtf8PathError}, }, Node, @@ -532,7 +532,7 @@ pub async fn light_scan_location( pub async fn relink_location( Library { db, id, sync, .. }: &Library, location_path: impl AsRef, -) -> Result<(), LocationError> { +) -> Result { let location_path = location_path.as_ref(); let mut metadata = SpacedriveLocationMetadataFile::try_load(&location_path) .await? @@ -547,7 +547,7 @@ pub async fn relink_location( .ok_or_else(|| NonUtf8PathError(location_path.into()))?; sync.write_op( - db, + &db, sync.shared_update( prisma_sync::location::SyncId { pub_id: pub_id.clone(), @@ -556,13 +556,23 @@ pub async fn relink_location( json!(path), ), db.location().update( - location::pub_id::equals(pub_id), + location::pub_id::equals(pub_id.clone()), vec![location::path::set(Some(path))], ), ) .await?; - Ok(()) + let location_id = db + .location() + .find_unique(location::pub_id::equals(pub_id)) + .select(location::select!({ id })) + .exec() + .await? + .ok_or_else(|| { + LocationError::MissingField(MissingFieldError::new("missing id of location")) + })?; + + Ok(location_id.id) } #[derive(Debug)] diff --git a/core/src/util/db.rs b/core/src/util/db.rs index 03b23391d45a..6e8d2554df51 100644 --- a/core/src/util/db.rs +++ b/core/src/util/db.rs @@ -69,6 +69,13 @@ pub fn inode_to_db(inode: u64) -> Vec { #[error("Missing field {0}")] pub struct MissingFieldError(&'static str); +impl MissingFieldError { + #[must_use] + pub const fn new(value: &'static str) -> Self { + Self(value) + } +} + impl From for rspc::Error { fn from(value: MissingFieldError) -> Self { rspc::Error::with_cause( diff --git a/interface/app/$libraryId/Explorer/store.ts b/interface/app/$libraryId/Explorer/store.ts index 451dccac6574..632c9b700329 100644 --- a/interface/app/$libraryId/Explorer/store.ts +++ b/interface/app/$libraryId/Explorer/store.ts @@ -8,7 +8,8 @@ import { type ExplorerItem, type ExplorerLayout, type ExplorerSettings, - type SortOrder + type SortOrder, + JobGroup } from '@sd/client'; export enum ExplorerKind { @@ -121,6 +122,7 @@ const state = { tagAssignMode: false, showInspector: false, showMoreInfo: false, + jobsToRedirect: [] as {locationId: number | null}[], mediaPlayerVolume: 0.7, newThumbnails: proxySet() as Set, cutCopyState: { type: 'Idle' } as CutCopyState, diff --git a/interface/app/$libraryId/Layout/Sidebar/LocationsContextMenu.tsx b/interface/app/$libraryId/Layout/Sidebar/LocationsContextMenu.tsx index 0c07dba3877f..1bbe6d5413da 100644 --- a/interface/app/$libraryId/Layout/Sidebar/LocationsContextMenu.tsx +++ b/interface/app/$libraryId/Layout/Sidebar/LocationsContextMenu.tsx @@ -1,5 +1,6 @@ import { Pencil, Plus, Trash } from '@phosphor-icons/react'; import { useNavigate } from 'react-router'; +import { useLibraryContext } from '@sd/client'; import { ContextMenu as CM, dialogManager, toast } from '@sd/ui'; import { AddLocationDialog } from '~/app/$libraryId/settings/library/locations/AddLocationDialog'; import DeleteDialog from '~/app/$libraryId/settings/library/locations/DeleteDialog'; @@ -14,6 +15,8 @@ interface Props { export default ({ children, locationId }: Props) => { const navigate = useNavigate(); const platform = usePlatform(); + const libraryId = useLibraryContext().library.uuid; + return ( { const path = await openDirectoryPickerDialog(platform); if (path !== '') { dialogManager.create((dp) => ( - + )); } } catch (error) { diff --git a/interface/app/$libraryId/settings/library/locations/AddLocationButton.tsx b/interface/app/$libraryId/settings/library/locations/AddLocationButton.tsx index 8735553f6da4..1fb8ec358d90 100644 --- a/interface/app/$libraryId/settings/library/locations/AddLocationButton.tsx +++ b/interface/app/$libraryId/settings/library/locations/AddLocationButton.tsx @@ -2,6 +2,8 @@ import { FolderSimplePlus } from '@phosphor-icons/react'; import clsx from 'clsx'; import { motion } from 'framer-motion'; import { useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useLibraryContext } from '@sd/client'; import { Button, dialogManager, type ButtonProps } from '@sd/ui'; import { useCallbackToWatchResize } from '~/hooks'; import { usePlatform } from '~/util/Platform'; @@ -16,6 +18,9 @@ interface AddLocationButton extends ButtonProps { export const AddLocationButton = ({ path, className, onClick, ...props }: AddLocationButton) => { const platform = usePlatform(); + const libraryId = useLibraryContext().library.uuid; + const navigate = useNavigate(); + const transition = { type: 'keyframes', ease: 'easeInOut', @@ -49,7 +54,7 @@ export const AddLocationButton = ({ path, className, onClick, ...props }: AddLoc // Remember `path` will be `undefined` on web cause the user has to provide it in the modal if (path !== '') dialogManager.create((dp) => ( - + )); onClick?.(); diff --git a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx index aaa16bf2e0db..3dbaaf30fe0f 100644 --- a/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx +++ b/interface/app/$libraryId/settings/library/locations/AddLocationDialog.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import { Controller, get } from 'react-hook-form'; +import { useNavigate } from 'react-router'; import { useDebouncedCallback } from 'use-debounce'; import { extractInfoRSPCError, @@ -9,7 +10,8 @@ import { usePlausibleEvent, useZodForm } from '@sd/client'; -import { Dialog, ErrorMessage, toast, useDialog, UseDialogProps, z } from '@sd/ui'; +import { CheckBox, Dialog, ErrorMessage, Label, toast, useDialog, UseDialogProps, z } from '@sd/ui'; +import { getExplorerStore, useExplorerStore } from '~/app/$libraryId/Explorer/store'; import Accordion from '~/components/Accordion'; import { useCallbackToWatchForm } from '~/hooks'; import { usePlatform } from '~/util/Platform'; @@ -34,13 +36,15 @@ const isRemoteErrorFormMessage = (message: unknown): message is RemoteErrorFormM const schema = z.object({ path: z.string().min(1), method: z.enum(Object.keys(REMOTE_ERROR_FORM_MESSAGE) as UnionToTuple), - indexerRulesIds: z.array(z.number()) + indexerRulesIds: z.array(z.number()), + shouldRedirect: z.boolean() }); type SchemaType = z.infer; export interface AddLocationDialog extends UseDialogProps { path: string; + libraryId: string; method?: RemoteErrorFormMessage; } @@ -56,6 +60,7 @@ export const AddLocationDialog = ({ const relinkLocation = useLibraryMutation('locations.relink'); const listIndexerRules = useLibraryQuery(['locations.indexer_rules.list']); const addLocationToLibrary = useLibraryMutation('locations.addLibrary'); + const explorerStore = useExplorerStore(); // This is required because indexRules is undefined on first render const indexerRulesIds = useMemo( @@ -65,7 +70,7 @@ export const AddLocationDialog = ({ const form = useZodForm({ schema, - defaultValues: { path, method, indexerRulesIds } + defaultValues: { path, method, indexerRulesIds, shouldRedirect: true } }); useEffect(() => { @@ -78,10 +83,12 @@ export const AddLocationDialog = ({ }, [form, path, indexerRulesIds]); const addLocation = useCallback( - async ({ path, method, indexerRulesIds }: SchemaType, dryRun = false) => { + async ({ path, method, indexerRulesIds, shouldRedirect }: SchemaType, dryRun = false) => { + let id = null; + switch (method) { case 'CREATE': - await createLocation.mutateAsync({ + id = await createLocation.mutateAsync({ path, dry_run: dryRun, indexer_rules_ids: indexerRulesIds @@ -91,7 +98,7 @@ export const AddLocationDialog = ({ break; case 'NEED_RELINK': - if (!dryRun) await relinkLocation.mutateAsync(path); + if (!dryRun) id = await relinkLocation.mutateAsync(path); // TODO: Update relinked location with new indexer rules, don't have a way to get location id yet though // await updateLocation.mutateAsync({ // id: locationId, @@ -104,7 +111,7 @@ export const AddLocationDialog = ({ break; case 'ADD_LIBRARY': - await addLocationToLibrary.mutateAsync({ + id = await addLocationToLibrary.mutateAsync({ path, dry_run: dryRun, indexer_rules_ids: indexerRulesIds @@ -116,8 +123,14 @@ export const AddLocationDialog = ({ default: throw new Error('Unimplemented custom remote error handling'); } + if (shouldRedirect) { + getExplorerStore().jobsToRedirect = [ + { locationId: id }, + ...explorerStore.jobsToRedirect + ]; + } }, - [createLocation, relinkLocation, addLocationToLibrary, submitPlausibleEvent] + [createLocation, relinkLocation, addLocationToLibrary, submitPlausibleEvent, explorerStore] ); const handleAddError = useCallback( @@ -206,27 +219,38 @@ export const AddLocationDialog = ({ : '' } > - - - - - - - - ( - - )} - control={form.control} +
+ - + + + + + +
+ + +
+ + + ( + + )} + control={form.control} + /> + +
); }; diff --git a/interface/app/index.tsx b/interface/app/index.tsx index 1081611bd13e..d6d3bed8e937 100644 --- a/interface/app/index.tsx +++ b/interface/app/index.tsx @@ -1,9 +1,9 @@ -import { useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import { Navigate, Outlet, useMatches, type RouteObject } from 'react-router-dom'; import { currentLibraryCache, useCachedLibraries, useInvalidateQuery } from '@sd/client'; -import { Dialogs, toast, Toaster } from '@sd/ui'; +import { Dialogs, Toaster } from '@sd/ui'; import { RouterErrorBoundary } from '~/ErrorFallback'; -import { useKeybindHandler, useTheme } from '~/hooks'; +import { useKeybindHandler, useShouldRedirect, useTheme } from '~/hooks'; import libraryRoutes from './$libraryId'; import onboardingRoutes from './onboarding'; @@ -11,8 +11,6 @@ import { RootContext } from './RootContext'; import './style.scss'; -import { usePlatform } from '..'; - const Index = () => { const libraries = useCachedLibraries(); @@ -31,6 +29,7 @@ const Wrapper = () => { useKeybindHandler(); useInvalidateQuery(); useTheme(); + useShouldRedirect(); const rawPath = useRawRoutePath(); diff --git a/interface/hooks/index.ts b/interface/hooks/index.ts index fa25ba9bbf07..01c57c212b7a 100644 --- a/interface/hooks/index.ts +++ b/interface/hooks/index.ts @@ -23,3 +23,4 @@ export * from './useIsTextTruncated'; export * from './useKeyMatcher'; export * from './useKeyCopyCutPaste'; export * from './useMouseNavigate'; +export * from './useShouldRedirect'; diff --git a/interface/hooks/useShouldRedirect.ts b/interface/hooks/useShouldRedirect.ts new file mode 100644 index 000000000000..6ef1834b222d --- /dev/null +++ b/interface/hooks/useShouldRedirect.ts @@ -0,0 +1,58 @@ +import { getExplorerStore, useExplorerStore } from "~/app/$libraryId/Explorer/store" +import { useLibraryQuery} from '@sd/client'; +import { useCallback, useEffect } from "react"; +import { useNavigate } from "react-router"; +import { useZodRouteParams } from "../hooks/useZodRouteParams"; +import { LibraryIdParamsSchema } from "../app/route-schemas"; +/** + * When a user adds a location and checks the should redirect box, + * this hook will redirect them to the location + * once the indexer has been invoked + */ + +export const useShouldRedirect = () => { + const { jobsToRedirect } = useExplorerStore(); + const navigate = useNavigate(); + const { libraryId } = useZodRouteParams(LibraryIdParamsSchema); + const jobGroups = useLibraryQuery(['jobs.reports'], { + enabled: !!(jobsToRedirect.length > 0), + refetchOnWindowFocus: false, + }); + + //We loop all job groups and pull the first job that matches the location id from the job group + + const pullMatchingJob = useCallback(() => { + if (jobsToRedirect.length === 0) return; + let jobFound + if (jobGroups.data) { + for (const jobGroup of jobGroups.data) { + for (const job of jobGroup.jobs) { + if (job.name === 'indexer') { + const locationId = jobsToRedirect.find((l) => l.locationId === job.metadata.location.id)?.locationId + if (job.metadata.location.id === locationId && job.completed_task_count > 0) { + jobFound = job; + break; + } + } + } + } + } + return jobFound + }, [jobGroups.data, jobsToRedirect]) + + //Once we have a matching job, we redirect the user to the location + + useEffect(() => { + if (jobGroups.data) { + const matchingJob = pullMatchingJob(); + if (matchingJob) { + const locationId = jobsToRedirect.find((l) => l.locationId === matchingJob.metadata.location.id)?.locationId + navigate(`/${libraryId}/location/${locationId}`); + getExplorerStore().jobsToRedirect = jobsToRedirect.filter((l) => l.locationId !== matchingJob.metadata.location.id); + } + } + }, [jobGroups.data, pullMatchingJob, navigate, libraryId, jobsToRedirect]) + +} + + diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index 60dbeff54d86..941f66394ed0 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -73,13 +73,13 @@ export type Procedures = { { key: "library.create", input: CreateLibraryArgs, result: LibraryConfigWrapped } | { key: "library.delete", input: string, result: null } | { key: "library.edit", input: EditLibraryArgs, result: null } | - { key: "locations.addLibrary", input: LibraryArgs, result: null } | - { key: "locations.create", input: LibraryArgs, result: null } | + { key: "locations.addLibrary", input: LibraryArgs, result: number | null } | + { key: "locations.create", input: LibraryArgs, result: number | null } | { key: "locations.delete", input: LibraryArgs, result: null } | { key: "locations.fullRescan", input: LibraryArgs, result: null } | { key: "locations.indexer_rules.create", input: LibraryArgs, result: null } | { key: "locations.indexer_rules.delete", input: LibraryArgs, result: null } | - { key: "locations.relink", input: LibraryArgs, result: null } | + { key: "locations.relink", input: LibraryArgs, result: number } | { key: "locations.subPathRescan", input: LibraryArgs, result: null } | { key: "locations.update", input: LibraryArgs, result: null } | { key: "nodes.edit", input: ChangeNodeNameArgs, result: null } |