mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-16 20:39:05 -04:00
[ENG-1418] Explorer settings (#1764)
* preferences * useShortcut and move location id to function * void * rescan
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { createContext, PropsWithChildren, useContext } from 'react';
|
||||
import { ContextType, createContext, PropsWithChildren, useContext } from 'react';
|
||||
|
||||
import { Ordering } from './store';
|
||||
import { UseExplorer } from './useExplorer';
|
||||
@@ -9,12 +9,16 @@ import { UseExplorer } from './useExplorer';
|
||||
*/
|
||||
const ExplorerContext = createContext<UseExplorer<Ordering> | null>(null);
|
||||
|
||||
export const useExplorerContext = () => {
|
||||
type ExplorerContext = NonNullable<ContextType<typeof ExplorerContext>>;
|
||||
|
||||
export const useExplorerContext = <T extends boolean = true>(
|
||||
{ suspense }: { suspense?: T } = { suspense: true as T }
|
||||
) => {
|
||||
const ctx = useContext(ExplorerContext);
|
||||
|
||||
if (ctx === null) throw new Error('ExplorerContext.Provider not found!');
|
||||
if (suspense && ctx === null) throw new Error('ExplorerContext.Provider not found!');
|
||||
|
||||
return ctx;
|
||||
return ctx as T extends true ? ExplorerContext : ExplorerContext | undefined;
|
||||
};
|
||||
|
||||
export const ExplorerContextProvider = <TExplorer extends UseExplorer<any>>({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
ArrowClockwise,
|
||||
Icon,
|
||||
Key,
|
||||
MonitorPlay,
|
||||
@@ -13,10 +12,9 @@ import clsx from 'clsx';
|
||||
import { useMemo } from 'react';
|
||||
import { useDocumentEventListener } from 'rooks';
|
||||
import { ExplorerLayout } from '@sd/client';
|
||||
import { ModifierKeys, toast } from '@sd/ui';
|
||||
import { useKeybind, useKeyMatcher, useOperatingSystem } from '~/hooks';
|
||||
import { toast } from '@sd/ui';
|
||||
import { useKeyMatcher } from '~/hooks';
|
||||
|
||||
import { useQuickRescan } from '../../../hooks/useQuickRescan';
|
||||
import { KeyManager } from '../KeyManager';
|
||||
import TopBarOptions, { ToolOption, TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
|
||||
import { useExplorerContext } from './Context';
|
||||
@@ -34,7 +32,6 @@ export const useExplorerTopBarOptions = () => {
|
||||
const explorer = useExplorerContext();
|
||||
const controlIcon = useKeyMatcher('Meta').icon;
|
||||
const settings = explorer.useSettingsSnapshot();
|
||||
const rescan = useQuickRescan();
|
||||
|
||||
const viewOptions = useMemo(
|
||||
() =>
|
||||
@@ -49,7 +46,8 @@ export const useExplorerTopBarOptions = () => {
|
||||
toolTipLabel: `${layout} view`,
|
||||
icon: <Icon className={TOP_BAR_ICON_STYLE} />,
|
||||
keybinds: [controlIcon, (i + 1).toString()],
|
||||
topBarActive: settings.layoutMode === layout,
|
||||
topBarActive:
|
||||
!explorer.isLoadingPreferences && settings.layoutMode === layout,
|
||||
onClick: () => (explorer.settingsStore.layoutMode = layout),
|
||||
showAtResolution: 'sm:flex'
|
||||
} satisfies ToolOption & { layout: ExplorerLayout };
|
||||
@@ -58,7 +56,13 @@ export const useExplorerTopBarOptions = () => {
|
||||
},
|
||||
[] as (ToolOption & { layout: ExplorerLayout })[]
|
||||
),
|
||||
[controlIcon, explorer.layouts, explorer.settingsStore, settings.layoutMode]
|
||||
[
|
||||
controlIcon,
|
||||
explorer.isLoadingPreferences,
|
||||
explorer.layouts,
|
||||
explorer.settingsStore,
|
||||
settings.layoutMode
|
||||
]
|
||||
);
|
||||
|
||||
const controlOptions: ToolOption[] = [
|
||||
@@ -87,12 +91,6 @@ export const useExplorerTopBarOptions = () => {
|
||||
}
|
||||
];
|
||||
|
||||
const { parent } = useExplorerContext();
|
||||
|
||||
const os = useOperatingSystem();
|
||||
|
||||
useKeybind([os === 'macOS' ? ModifierKeys.Meta : ModifierKeys.Control, 'r'], () => rescan());
|
||||
|
||||
useDocumentEventListener('keydown', (e: unknown) => {
|
||||
if (!(e instanceof KeyboardEvent)) return;
|
||||
|
||||
@@ -128,13 +126,6 @@ export const useExplorerTopBarOptions = () => {
|
||||
topBarActive: explorerStore.tagAssignMode,
|
||||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
},
|
||||
parent?.type === 'Location' && {
|
||||
toolTipLabel: 'Reload',
|
||||
onClick: rescan,
|
||||
icon: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />,
|
||||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
}
|
||||
].filter(Boolean) as ToolOption[];
|
||||
|
||||
@@ -145,12 +136,16 @@ export const useExplorerTopBarOptions = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const DefaultTopBarOptions = () => {
|
||||
export const DefaultTopBarOptions = (props: { options?: ToolOption[] }) => {
|
||||
const options = useExplorerTopBarOptions();
|
||||
|
||||
return (
|
||||
<TopBarOptions
|
||||
options={[options.viewOptions, options.toolOptions, options.controlOptions]}
|
||||
options={[
|
||||
options.viewOptions,
|
||||
[...options.toolOptions, ...(props.options ?? [])],
|
||||
options.controlOptions
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { proxy, snapshot, subscribe, useSnapshot } from 'valtio';
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
@@ -39,6 +40,7 @@ export interface UseExplorerProps<TOrder extends Ordering> {
|
||||
parent?: ExplorerParent;
|
||||
loadMore?: () => void;
|
||||
isFetchingNextPage?: boolean;
|
||||
isLoadingPreferences?: boolean;
|
||||
scrollRef?: RefObject<HTMLDivElement>;
|
||||
/**
|
||||
* @defaultValue `true`
|
||||
@@ -98,33 +100,36 @@ export function useExplorerSettings<TOrder extends Ordering>({
|
||||
location
|
||||
}: {
|
||||
settings: ReturnType<typeof createDefaultExplorerSettings<TOrder>>;
|
||||
onSettingsChanged?: (settings: ExplorerSettings<TOrder>) => any;
|
||||
onSettingsChanged?: (settings: ExplorerSettings<TOrder>, location: Location) => void;
|
||||
orderingKeys?: z.ZodUnion<
|
||||
[z.ZodLiteral<OrderingKeys<TOrder>>, ...z.ZodLiteral<OrderingKeys<TOrder>>[]]
|
||||
>;
|
||||
location?: Location | null;
|
||||
}) {
|
||||
const [store, setStore] = useState(() => proxy(settings));
|
||||
const [store] = useState(() => proxy(settings));
|
||||
|
||||
useEffect(() => {
|
||||
Object.assign(store, {
|
||||
...settings,
|
||||
...store
|
||||
});
|
||||
}, [store, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
setStore(proxy(settings));
|
||||
}, [location, settings]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe(store, () => {
|
||||
onSettingsChanged?.(snapshot(store) as ExplorerSettings<TOrder>);
|
||||
}),
|
||||
[onSettingsChanged, store]
|
||||
const updateSettings = useDebouncedCallback(
|
||||
(settings: ExplorerSettings<TOrder>, location: Location) => {
|
||||
onSettingsChanged?.(settings, location);
|
||||
},
|
||||
500
|
||||
);
|
||||
|
||||
useEffect(() => updateSettings.flush(), [location, updateSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateSettings.isPending()) return;
|
||||
Object.assign(store, settings);
|
||||
}, [settings, store, updateSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSettingsChanged || !location) return;
|
||||
const unsubscribe = subscribe(store, () => {
|
||||
updateSettings(snapshot(store) as ExplorerSettings<TOrder>, location);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [store, updateSettings, location, onSettingsChanged]);
|
||||
|
||||
return {
|
||||
useSettingsSnapshot: () => useSnapshot(store),
|
||||
settingsStore: store,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Info } from '@phosphor-icons/react';
|
||||
import { ArrowClockwise, Info } from '@phosphor-icons/react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { stringify } from 'uuid';
|
||||
import {
|
||||
arraysEqual,
|
||||
ExplorerSettings,
|
||||
FilePathFilterArgs,
|
||||
FilePathOrder,
|
||||
Location,
|
||||
ObjectKindEnum,
|
||||
useLibraryContext,
|
||||
useLibraryMutation,
|
||||
@@ -18,7 +18,14 @@ import {
|
||||
import { Loader, Tooltip } from '@sd/ui';
|
||||
import { LocationIdParamsSchema } from '~/app/route-schemas';
|
||||
import { Folder, Icon } from '~/components';
|
||||
import { useIsLocationIndexing, useKeyDeleteFile, useZodRouteParams } from '~/hooks';
|
||||
import {
|
||||
useIsLocationIndexing,
|
||||
useKeyDeleteFile,
|
||||
useOperatingSystem,
|
||||
useShortcut,
|
||||
useZodRouteParams
|
||||
} from '~/hooks';
|
||||
import { useQuickRescan } from '~/hooks/useQuickRescan';
|
||||
|
||||
import Explorer from '../Explorer';
|
||||
import { ExplorerContextProvider } from '../Explorer/Context';
|
||||
@@ -29,16 +36,21 @@ import { useExplorer, UseExplorerSettings, useExplorerSettings } from '../Explor
|
||||
import { useExplorerSearchParams } from '../Explorer/util';
|
||||
import { EmptyNotice } from '../Explorer/View';
|
||||
import { TopBarPortal } from '../TopBar/Portal';
|
||||
import { TOP_BAR_ICON_STYLE } from '../TopBar/TopBarOptions';
|
||||
import LocationOptions from './LocationOptions';
|
||||
|
||||
export const Component = () => {
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
|
||||
const location = useLibraryQuery(['locations.get', locationId]);
|
||||
const os = useOperatingSystem();
|
||||
const rspc = useRspcLibraryContext();
|
||||
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
const { id: locationId } = useZodRouteParams(LocationIdParamsSchema);
|
||||
|
||||
const location = useLibraryQuery(['locations.get', locationId]);
|
||||
const onlineLocations = useOnlineLocations();
|
||||
|
||||
const rescan = useQuickRescan();
|
||||
|
||||
const locationOnline = useMemo(() => {
|
||||
const pub_id = location.data?.pub_id;
|
||||
if (!pub_id) return false;
|
||||
@@ -70,21 +82,23 @@ export const Component = () => {
|
||||
return defaults;
|
||||
}, [location.data, preferences.data?.location]);
|
||||
|
||||
const onSettingsChanged = useDebouncedCallback(
|
||||
async (settings: ExplorerSettings<FilePathOrder>) => {
|
||||
if (!location.data) return;
|
||||
const pubId = stringify(location.data.pub_id);
|
||||
try {
|
||||
await updatePreferences.mutateAsync({
|
||||
location: { [pubId]: { explorer: settings } }
|
||||
});
|
||||
rspc.queryClient.invalidateQueries(['preferences.get']);
|
||||
} catch (e) {
|
||||
alert('An error has occurred while updating your preferences.');
|
||||
}
|
||||
},
|
||||
500
|
||||
);
|
||||
const onSettingsChanged = async (
|
||||
settings: ExplorerSettings<FilePathOrder>,
|
||||
location: Location
|
||||
) => {
|
||||
if (location.id === locationId && preferences.isLoading) return;
|
||||
|
||||
const pubId = stringify(location.pub_id);
|
||||
|
||||
try {
|
||||
await updatePreferences.mutateAsync({
|
||||
location: { [pubId]: { explorer: settings } }
|
||||
});
|
||||
rspc.queryClient.invalidateQueries(['preferences.get']);
|
||||
} catch (e) {
|
||||
alert('An error has occurred while updating your preferences.');
|
||||
}
|
||||
};
|
||||
|
||||
const explorerSettings = useExplorerSettings({
|
||||
settings,
|
||||
@@ -100,6 +114,7 @@ export const Component = () => {
|
||||
count,
|
||||
loadMore,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
isLoadingPreferences: preferences.isLoading,
|
||||
settings: explorerSettings,
|
||||
...(location.data && {
|
||||
parent: { type: 'Location', location: location.data }
|
||||
@@ -117,9 +132,11 @@ export const Component = () => {
|
||||
explorer.resetSelectedItems.call(undefined);
|
||||
}, [explorer.resetSelectedItems, path]);
|
||||
|
||||
useEffect(() => explorer.scrollRef.current?.scrollTo({ top: 0 }), [explorer.scrollRef, path]);
|
||||
|
||||
useKeyDeleteFile(explorer.selectedItems, location.data?.id);
|
||||
|
||||
useEffect(() => explorer.scrollRef.current?.scrollTo({ top: 0 }), [explorer.scrollRef, path]);
|
||||
useShortcut('rescan', () => rescan(locationId));
|
||||
|
||||
return (
|
||||
<ExplorerContextProvider explorer={explorer}>
|
||||
@@ -142,14 +159,26 @@ export const Component = () => {
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right={<DefaultTopBarOptions />}
|
||||
right={
|
||||
<DefaultTopBarOptions
|
||||
options={[
|
||||
{
|
||||
toolTipLabel: 'Reload',
|
||||
onClick: () => rescan(locationId),
|
||||
icon: <ArrowClockwise className={TOP_BAR_ICON_STYLE} />,
|
||||
individual: true,
|
||||
showAtResolution: 'xl:flex'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLocationIndexing ? (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader />
|
||||
</div>
|
||||
) : (
|
||||
) : !preferences.isLoading ? (
|
||||
<Explorer
|
||||
emptyNotice={
|
||||
<EmptyNotice
|
||||
@@ -159,7 +188,7 @@ export const Component = () => {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</ExplorerContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,8 +25,7 @@ export const Component = () => {
|
||||
order: null
|
||||
}),
|
||||
[]
|
||||
),
|
||||
onSettingsChanged: () => {}
|
||||
)
|
||||
});
|
||||
|
||||
const explorer = useExplorer({
|
||||
|
||||
@@ -12,27 +12,30 @@ export const useQuickRescan = () => {
|
||||
// gotta clean up any rescan subscriptions if the exist
|
||||
useEffect(() => () => quickRescanSubscription.current?.(), []);
|
||||
const { client } = useRspcLibraryContext();
|
||||
const { parent } = useExplorerContext();
|
||||
const explorer = useExplorerContext({ suspense: false });
|
||||
const [{ path }] = useExplorerSearchParams();
|
||||
|
||||
const rescan = () => {
|
||||
if (parent?.type === 'Location') {
|
||||
quickRescanSubscription.current?.();
|
||||
quickRescanSubscription.current = client.addSubscription(
|
||||
[
|
||||
'locations.quickRescan',
|
||||
{
|
||||
location_id: parent.location.id,
|
||||
sub_path: path ?? ''
|
||||
}
|
||||
],
|
||||
{ onData() {} }
|
||||
);
|
||||
const rescan = (id?: number) => {
|
||||
const locationId =
|
||||
id ?? (explorer?.parent?.type === 'Location' ? explorer.parent.location.id : undefined);
|
||||
|
||||
toast.success({
|
||||
title: `Quick rescan started`
|
||||
});
|
||||
}
|
||||
if (locationId === undefined) return;
|
||||
|
||||
quickRescanSubscription.current?.();
|
||||
quickRescanSubscription.current = client.addSubscription(
|
||||
[
|
||||
'locations.quickRescan',
|
||||
{
|
||||
location_id: locationId,
|
||||
sub_path: path ?? ''
|
||||
}
|
||||
],
|
||||
{ onData() {} }
|
||||
);
|
||||
|
||||
toast.success({
|
||||
title: `Quick rescan started`
|
||||
});
|
||||
};
|
||||
|
||||
return rescan;
|
||||
|
||||
Reference in New Issue
Block a user