This commit is contained in:
nikec 2024-06-26 10:29:10 +02:00
parent 20b8a2b93b
commit f97303ff54
19 changed files with 324 additions and 74 deletions

View file

@ -93,6 +93,7 @@ pub enum ExplorerLayout {
Grid,
List,
Media,
Columns,
}
#[derive(Clone, Serialize, Deserialize, Type, Debug)]

View file

@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { Slider } from '@sd/ui';
import { useLocale } from '~/hooks';
@ -15,21 +14,18 @@ export const IconSize = () => {
const explorer = useExplorerContext();
const settings = explorer.useSettingsSnapshot();
const defaultValue = useMemo(
() => sizes.findIndex((size) => size[0] === settings.listViewIconSize),
[settings.listViewIconSize]
);
const defaultValue = sizes.indexMap.get(settings.listViewIconSize)!;
return (
<div>
<Subheading>{t('icon_size')}</Subheading>
<Slider
step={1}
max={sizes.length - 1}
max={sizes.indexMap.size - 1}
defaultValue={[defaultValue]}
onValueChange={([value]) => {
const size = value !== undefined && sizes[value];
if (size) explorer.settingsStore.listViewIconSize = size[0];
const size = value !== undefined && sizes.sizeMap.get(value);
if (size) explorer.settingsStore.listViewIconSize = size;
}}
/>
</div>

View file

@ -1,4 +1,3 @@
import { useMemo } from 'react';
import { Slider } from '@sd/ui';
import { useLocale } from '~/hooks';
@ -15,21 +14,18 @@ export const TextSize = () => {
const explorer = useExplorerContext();
const settings = explorer.useSettingsSnapshot();
const defaultValue = useMemo(
() => sizes.findIndex((size) => size[0] === settings.listViewTextSize),
[settings.listViewTextSize]
);
const defaultValue = sizes.indexMap.get(settings.listViewTextSize)!;
return (
<div>
<Subheading>{t('text_size')}</Subheading>
<Slider
step={1}
max={sizes.length - 1}
max={sizes.indexMap.size - 1}
defaultValue={[defaultValue]}
onValueChange={([value]) => {
const size = value !== undefined && sizes[value];
if (size) explorer.settingsStore.listViewTextSize = size[0];
const size = value !== undefined && sizes.sizeMap.get(value);
if (size) explorer.settingsStore.listViewTextSize = size;
}}
/>
</div>

View file

@ -1,3 +1,20 @@
export function getSizes<T extends { [key: string]: number }>(sizes: T) {
return (Object.entries(sizes) as [keyof T, T[keyof T]][]).sort((a, b) => a[1] - b[1]);
console.log('gen sizes');
const sizesArr = (Object.entries(sizes) as [keyof T, T[keyof T]][]).sort((a, b) => a[1] - b[1]);
// Map fo size to index
const indexMap = new Map<keyof T, number>();
// Map of index to size
const sizeMap = new Map<number, keyof T>();
for (let i = 0; i < sizesArr.length; i++) {
const size = sizesArr[i];
if (!size) continue;
indexMap.set(size[0], i);
sizeMap.set(i, size[0]);
}
return { indexMap, sizeMap };
}

View file

@ -6,6 +6,7 @@ import {
SidebarSimple,
SlidersHorizontal,
SquaresFour,
SquareSplitHorizontal,
Tag
} from '@phosphor-icons/react';
import clsx from 'clsx';
@ -25,7 +26,8 @@ import { explorerStore } from './store';
const layoutIcons: Record<ExplorerLayout, Icon> = {
grid: SquaresFour,
list: Rows,
media: MonitorPlay
media: MonitorPlay,
columns: SquareSplitHorizontal
};
export const useExplorerTopBarOptions = () => {

View file

@ -0,0 +1,53 @@
import { CaretRight } from '@phosphor-icons/react';
import clsx from 'clsx';
import { memo } from 'react';
import { getItemFilePath, type ExplorerItem } from '@sd/client';
import { FileThumb } from '../../FilePath/Thumb';
import { RenamableItemText } from '../RenamableItemText';
export interface ColumnsViewItemProps {
data: ExplorerItem;
selected?: boolean;
cut?: boolean;
}
export const ColumnsViewItem = memo((props: ColumnsViewItemProps) => {
const filePath = getItemFilePath(props.data);
const isHidden = filePath?.hidden;
const isFolder = filePath?.is_dir;
const isLocation = props.data.type === 'Location';
return (
<div
className={clsx(
'flex items-center rounded px-4 py-1 pr-3',
props.selected && 'bg-accent'
)}
>
<FileThumb
data={props.data}
frame
frameClassName={clsx('!border', props.data.type === 'Label' && '!rounded-lg')}
blackBars
size={24}
className={clsx('mr-0.5 transition-[height_width]', props.cut && 'opacity-60')}
/>
<div className="relative flex-1">
<RenamableItemText
item={props.data}
selected={props.selected}
allowHighlight={false}
style={{ fontSize: 13 }}
className="absolute top-1/2 z-10 max-w-full -translate-y-1/2"
idleClassName="!w-full"
editLines={3}
/>
</div>
{isFolder && <CaretRight weight="bold" size={10} opacity={0.5} />}
</div>
);
});

View file

@ -0,0 +1,67 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { useCallback } from 'react';
import { getExplorerItemData } from '@sd/client';
import { useExplorerContext } from '../../Context';
import { useExplorerViewContext } from '../Context';
import { ColumnsViewItem } from './Item';
const ROW_HEIGHT = 20;
export function ColumnsView() {
const explorer = useExplorerContext();
const explorerView = useExplorerViewContext();
const rowVirtualizer = useVirtualizer({
count: Math.max(explorerView.items?.length ?? 0, explorerView.count ?? 0),
getScrollElement: useCallback(() => explorerView.ref.current, [explorerView.ref]),
estimateSize: useCallback(() => ROW_HEIGHT, []),
paddingStart: 8,
paddingEnd: 8 + (explorerView.scrollPadding?.bottom ?? 0),
// scrollMargin: listOffset,
overscan: explorer.overscan ?? 10,
scrollPaddingStart: explorerView.scrollPadding?.top,
scrollPaddingEnd: explorerView.scrollPadding?.bottom
});
const virtualRows = rowVirtualizer.getVirtualItems();
return (
<div>
<div className="relative" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
<div
className="absolute left-0 top-0 min-w-full"
style={{
transform: `translateY(${
(virtualRows[0]?.start ?? 0) - rowVirtualizer.options.scrollMargin
}px)`
}}
>
{virtualRows.map((virtualRow) => {
const row = explorerView.items?.[virtualRow.index];
if (!row) return null;
const itemData = getExplorerItemData(row);
const previousRow = explorerView.items?.[virtualRow.index - 1];
const nextRow = explorerView.items?.[virtualRow.index + 1];
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className="relative"
>
<ColumnsViewItem
data={row}
// selected={itemData.name === 'spacedrive'}
/>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,11 @@
import { createContext, useContext, type ReactNode, type RefObject } from 'react';
export interface ExplorerViewContext {
import { useSelectedItems } from '../useExplorer';
import { useExplorerWindow } from '../useExplorerWindow';
export interface ExplorerViewContext
extends ReturnType<typeof useExplorerWindow>,
ReturnType<typeof useSelectedItems> {
ref: RefObject<HTMLDivElement>;
/**
* Padding to apply when scrolling to an item.

View file

@ -130,11 +130,11 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => {
};
if (!continueSelection) {
if (explorer.selectedItems.has(item.data)) {
if (explorerView.selectedItems.has(item.data)) {
// Keep previous selection as selecto will reset it otherwise
selecto.current?.setSelectedTargets(e.beforeSelected);
} else {
explorer.resetSelectedItems([item.data]);
explorerView.resetSelectedItems([item.data]);
selectedTargets.resetSelectedTargets([
{ id: String(item.id), node: element as HTMLElement }
]);
@ -143,8 +143,8 @@ export const DragSelect = ({ grid, children, onActiveItemChange }: Props) => {
return;
}
if (e.added[0]) explorer.addSelectedItem(item.data);
else explorer.removeSelectedItem(item.data);
if (e.added[0]) explorerView.addSelectedItem(item.data);
else explorerView.removeSelectedItem(item.data);
// Update active item for further keyboard selection.
onActiveItemChange(item.data, { updateFirstItem: true, setFirstItemAsChanged: true });

View file

@ -33,8 +33,8 @@ export const GridItem = ({ children, item, index, ...props }: Props) => {
const selected = useMemo(
// Even though this checks object equality, it should still be safe since `selectedItems`
// will be re-calculated before this memo runs.
() => explorer.selectedItems.has(item),
[explorer.selectedItems, item]
() => explorerView.selectedItems.has(item),
[explorerView.selectedItems, item]
);
const canGoBack = currentIndex !== 0;
@ -61,8 +61,8 @@ export const GridItem = ({ children, item, index, ...props }: Props) => {
}
}}
onContextMenu={(e) => {
if (!explorerView.selectable || explorer.selectedItems.has(item)) return;
explorer.resetSelectedItems([item]);
if (!explorerView.selectable || explorerView.selectedItems.has(item)) return;
explorerView.resetSelectedItems([item]);
dragSelect.resetSelectedTargets([{ id: uniqueId(item), node: e.currentTarget }]);
}}
>

View file

@ -20,9 +20,9 @@ export const GridView = () => {
const itemHeight = explorerSettings.gridItemSize + itemDetailsHeight;
const grid = useGrid({
scrollRef: explorer.scrollRef,
count: explorer.items?.length ?? 0,
totalCount: explorer.count,
scrollRef: explorerView.ref,
count: explorerView.items?.length ?? 0,
totalCount: explorerView.count,
columns: 'auto',
size: { width: explorerSettings.gridItemSize, height: itemHeight },
padding: {
@ -32,14 +32,14 @@ export const GridView = () => {
},
gap: explorerSettings.gridGap,
overscan: explorer.overscan ?? 5,
onLoadMore: explorer.loadMore,
onLoadMore: explorerView.loadMore,
getItemId: useCallback(
(index: number) => getItemId(index, explorer.items ?? []),
[explorer.items]
(index: number) => getItemId(index, explorerView.items ?? []),
[explorerView.items]
),
getItemData: useCallback(
(index: number) => getItemData(index, explorer.items ?? []),
[explorer.items]
(index: number) => getItemData(index, explorerView.items ?? []),
[explorerView.items]
)
});
@ -49,7 +49,7 @@ export const GridView = () => {
<DragSelect grid={grid} onActiveItemChange={updateActiveItem}>
<Grid grid={grid}>
{(index) => {
const item = explorer.items?.[index];
const item = explorerView.items?.[index];
if (!item) return null;
return (

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { CSSProperties, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useKeys } from 'rooks';
import {
@ -21,9 +21,12 @@ import { QuickPreview } from '../QuickPreview';
import { useQuickPreviewContext } from '../QuickPreview/Context';
import { getQuickPreviewStore, useQuickPreviewStore } from '../QuickPreview/store';
import { explorerStore } from '../store';
import { useSelectedItems } from '../useExplorer';
import { useExplorerDroppable } from '../useExplorerDroppable';
import { useExplorerOperatingSystem } from '../useExplorerOperatingSystem';
import { useExplorerWindow } from '../useExplorerWindow';
import { useExplorerSearchParams } from '../util';
import { ColumnsView } from './ColumnsView';
import { ViewContext, type ExplorerViewContext } from './Context';
import { DragScrollable } from './DragScrollable';
import { GridView } from './GridView';
@ -34,9 +37,18 @@ import { useViewItemDoubleClick } from './ViewItem';
export interface ExplorerViewProps
extends Omit<ExplorerViewContext, 'selectable' | 'ref' | 'padding'> {
emptyNotice?: JSX.Element;
path?: string;
style?: CSSProperties;
}
export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
export const View = ({
emptyNotice,
path: explorerPath,
style,
...contextProps
}: ExplorerViewProps) => {
const items = useExplorerWindow(explorerPath);
const { explorerOperatingSystem, matchingOperatingSystem } = useExplorerOperatingSystem();
const explorer = useExplorerContext();
@ -86,6 +98,8 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
})
});
const selected = useSelectedItems(items.items);
useShortcuts();
useShortcut('explorerEscape', () => {
@ -142,10 +156,11 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
if (!explorer.layouts[layoutMode]) return null;
return (
<ViewContext.Provider value={{ ref, ...contextProps, selectable }}>
<ViewContext.Provider value={{ ref, ...contextProps, selectable, ...items, ...selected }}>
<div
ref={ref}
className="flex flex-1"
className="custom-scroll explorer-scroll flex size-full overflow-y-auto overflow-x-hidden border-r border-app-line"
style={{ width: layoutMode === 'columns' ? 400 : undefined, ...style }}
onMouseDown={(e) => {
if (e.button !== 0) return;
@ -165,6 +180,7 @@ export const View = ({ emptyNotice, ...contextProps }: ExplorerViewProps) => {
{layoutMode === 'grid' && <GridView />}
{layoutMode === 'list' && <ListView />}
{layoutMode === 'media' && <MediaView />}
{layoutMode === 'columns' && <ColumnsView />}
{showLoading && (
<Loader className="fixed bottom-10 left-0 w-[calc(100%+180px)]" />
)}

View file

@ -1,5 +1,4 @@
import { FolderNotchOpen } from '@phosphor-icons/react';
import { CSSProperties, type PropsWithChildren, type ReactNode } from 'react';
import { CSSProperties, Suspense, type PropsWithChildren, type ReactNode } from 'react';
import {
explorerLayout,
useExplorerLayoutStore,
@ -10,7 +9,6 @@ import { useShortcut } from '~/hooks';
import { useTopBarContext } from '../TopBar/Context';
import { useExplorerContext } from './Context';
import ContextMenu from './ContextMenu';
import DismissibleNotice from './DismissibleNotice';
import { ExplorerPath, PATH_BAR_HEIGHT } from './ExplorerPath';
import { Inspector, INSPECTOR_WIDTH } from './Inspector';
@ -19,11 +17,15 @@ import { getQuickPreviewStore } from './QuickPreview/store';
import { explorerStore } from './store';
import { useKeyRevealFinder } from './useKeyRevealFinder';
import { ExplorerViewProps, View } from './View';
import { EmptyNotice } from './View/EmptyNotice';
import 'react-slidedown/lib/slidedown.css';
import { FolderNotchOpen } from '@phosphor-icons/react';
import ContextMenu from './ContextMenu';
import { useExplorerDnd } from './useExplorerDnd';
import { useExplorerSearchParams } from './util';
import { EmptyNotice } from './View/EmptyNotice';
interface Props {
emptyNotice?: ExplorerViewProps['emptyNotice'];
@ -39,6 +41,8 @@ export default function Explorer(props: PropsWithChildren<Props>) {
const layoutStore = useExplorerLayoutStore();
const showInspector = useSelector(explorerStore, (s) => s.showInspector);
const [{ path }] = useExplorerSearchParams();
const showPathBar = explorer.showPathBar && layoutStore.showPathBar;
// Can we put this somewhere else -_-
@ -76,39 +80,53 @@ export default function Explorer(props: PropsWithChildren<Props>) {
const topBar = useTopBarContext();
const paths = [undefined, ...(path?.split('/').filter(Boolean) ?? [])];
return (
<>
<ExplorerContextMenu>
<div
ref={explorer.scrollRef}
className="custom-scroll explorer-scroll flex flex-1 flex-col overflow-x-hidden"
style={
{
'--scrollbar-margin-top': `${topBar.topBarHeight}px`,
'--scrollbar-margin-bottom': `${showPathBar ? PATH_BAR_HEIGHT : 0}px`,
'paddingTop': topBar.topBarHeight,
'paddingRight': showInspector ? INSPECTOR_WIDTH : 0
} as CSSProperties
}
>
{explorer.items && explorer.items.length > 0 && <DismissibleNotice />}
<View
contextMenu={props.contextMenu ? props.contextMenu() : <ContextMenu />}
emptyNotice={
props.emptyNotice ?? (
<EmptyNotice
icon={FolderNotchOpen}
message="This folder is empty"
/>
)
}
listViewOptions={{ hideHeaderBorder: true }}
scrollPadding={{
top: topBar.topBarHeight,
bottom: showPathBar ? PATH_BAR_HEIGHT : undefined
}}
/>
<div className="flex flex-1 overflow-hidden">
<Suspense fallback={<SuspanceFb />}>
{paths.map((path, i) => {
const p = !path ? undefined : paths.slice(0, i + 1).join('/') + '/';
console.log('path', path, p);
return (
<View
key={path}
style={
{
'--scrollbar-margin-top': `${topBar.topBarHeight}px`,
'--scrollbar-margin-bottom': `${showPathBar ? PATH_BAR_HEIGHT : 0}px`,
'paddingTop': topBar.topBarHeight,
'paddingRight': showInspector ? INSPECTOR_WIDTH : 0
} as CSSProperties
}
path={p}
contextMenu={props.contextMenu?.() ?? <ContextMenu />}
emptyNotice={
props.emptyNotice ?? (
<EmptyNotice
icon={FolderNotchOpen}
message="This folder is empty"
/>
)
}
listViewOptions={{ hideHeaderBorder: true }}
scrollPadding={{
top: topBar.topBarHeight,
bottom: showPathBar ? PATH_BAR_HEIGHT : undefined
}}
/>
);
})}
</Suspense>
</div>
</div>
</ExplorerContextMenu>
@ -126,3 +144,9 @@ export default function Explorer(props: PropsWithChildren<Props>) {
</>
);
}
const SuspanceFb = () => {
console.log('Loading...');
return <div className="flex size-full items-center justify-center">Loading...</div>;
};

View file

@ -77,6 +77,7 @@ export function useExplorer<TOrder extends Ordering>({
grid: true,
list: true,
media: true,
columns: true,
...layouts
},
...settings,
@ -140,7 +141,7 @@ export type UseExplorerSettings<TOrder extends Ordering, T> = ReturnType<
typeof useExplorerSettings<TOrder, T>
>;
function useSelectedItems(items: ExplorerItem[] | null) {
export function useSelectedItems(items: ExplorerItem[] | null) {
// Doing pointer lookups for hashes is a bit faster than assembling a bunch of strings
// WeakMap ensures that ExplorerItems aren't held onto after they're evicted from cache
const itemHashesWeakMap = useRef(new WeakMap<ExplorerItem, string>());

View file

@ -0,0 +1,67 @@
import { useMemo } from 'react';
import { useLocationExplorerSettings } from '../location/$id';
import { useSearchFromSearchParams } from '../search';
import { useSearchExplorerQuery } from '../search/useSearchExplorerQuery';
import { useExplorerContext } from './Context';
import { explorerStore } from './store';
import { useExplorerSearchParams } from './util';
export function useExplorerWindow(path?: string) {
const explorer = useExplorerContext();
if (explorer.parent?.type !== 'Location') {
throw new Error('useExplorerWindow must be used within a LocationExplorer');
}
const location = explorer.parent.location;
const [{ take }] = useExplorerSearchParams();
const { explorerSettings, preferences } = useLocationExplorerSettings(location);
const { layoutMode, mediaViewWithDescendants, showHiddenFiles } =
explorerSettings.useSettingsSnapshot();
const defaultFilters = useMemo(
() => [{ filePath: { locations: { in: [location.id] } } }],
[location.id]
);
const search = useSearchFromSearchParams();
const searchFiltersAreDefault = useMemo(
() => JSON.stringify(defaultFilters) !== JSON.stringify(search.filters),
[defaultFilters, search.filters]
);
//
const items = useSearchExplorerQuery({
search,
explorerSettings,
filters: [
...(search.allFilters.length > 0 ? search.allFilters : defaultFilters),
{
filePath: {
path: {
location_id: location.id,
path: path ?? '',
include_descendants:
search.search !== '' ||
(search.filters &&
search.filters.length > 0 &&
searchFiltersAreDefault) ||
(layoutMode === 'media' && mediaViewWithDescendants)
}
}
},
...(!showHiddenFiles ? [{ filePath: { hidden: false } }] : [])
],
take,
paths: { order: explorerSettings.useSettingsSnapshot().order },
onSuccess: () => explorerStore.resetNewThumbnails()
});
return items;
}

View file

@ -81,6 +81,8 @@ const LocationExplorer = ({ location }: { location: Location; path?: string }) =
[defaultFilters, search.filters]
);
//
const items = useSearchExplorerQuery({
search,
explorerSettings,
@ -130,6 +132,8 @@ const LocationExplorer = ({ location }: { location: Location; path?: string }) =
useKeyDeleteFile(explorer.selectedItems, location.id);
//
useShortcut('rescan', () => rescan(location.id));
const title = useRouteTitle(
@ -175,6 +179,7 @@ const LocationExplorer = ({ location }: { location: Location; path?: string }) =
)}
</TopBarPortal>
</SearchContextProvider>
{isLocationIndexing ? (
<div className="flex size-full items-center justify-center">
<Loader />
@ -223,7 +228,7 @@ function getLastSectionOfPath(path: string): string | undefined {
return lastSection;
}
function useLocationExplorerSettings(location: Location) {
export function useLocationExplorerSettings(location: Location) {
const rspc = useRspcLibraryContext();
const preferences = useLibraryQuery(['preferences.get']);

View file

@ -67,5 +67,4 @@
"eslintConfig": {
"root": true
},
"packageManager": "pnpm@8.15.6+sha256.01c01eeb990e379b31ef19c03e9d06a14afa5250b82e81303f88721c99ff2e6f"
}
"packageManager": "pnpm@9.0.4"}

View file

@ -250,7 +250,7 @@ export type EphemeralRenameOne = { from_path: string; to: string }
export type ExplorerItem = { type: "Path"; thumbnail: string[] | null; item: FilePathWithObject } | { type: "Object"; thumbnail: string[] | null; item: ObjectWithFilePaths } | { type: "Location"; item: Location } | { type: "NonIndexedPath"; thumbnail: string[] | null; item: NonIndexedPathItem } | { type: "SpacedropPeer"; item: PeerMetadata } | { type: "Label"; thumbnails: string[][]; item: LabelWithObjects }
export type ExplorerLayout = "grid" | "list" | "media"
export type ExplorerLayout = "grid" | "list" | "media" | "columns"
export type ExplorerSettings<TOrder> = { layoutMode: ExplorerLayout | null; gridItemSize: number | null; gridGap: number | null; mediaColumns: number | null; mediaAspectSquare: boolean | null; mediaViewWithDescendants: boolean | null; openOnDoubleClick: DoubleClickAction | null; showBytesInGridView: boolean | null; colVisibility: { [key in string]: boolean } | null; colSizes: { [key in string]: number } | null; listViewIconSize: string | null; listViewTextSize: string | null; order?: TOrder | null; showHiddenFiles?: boolean }

View file

@ -57,7 +57,8 @@ export function usePathsOffsetInfiniteQuery({
if (nodes.length >= arg.take) return (offset ?? 0) + 1;
},
onSuccess,
...args
...args,
suspense: true
});
const nodes = useMemo(