mirror of
https://github.com/spacedriveapp/spacedrive
synced 2024-07-07 00:53:28 +00:00
[ENG-1114] List view improvements (#1371)
* almost there * so close * fix drag scroll * rerender * Update index.tsx * fix y scroll * scroll * header border * remove react-scroll-sync
This commit is contained in:
parent
052028a9c7
commit
e3d69fe1b5
|
@ -142,7 +142,7 @@ export default ({ children }: { children: RenderItem }) => {
|
|||
[explorer.items]
|
||||
),
|
||||
getItemData: useCallback((index: number) => explorer.items?.[index], [explorer.items]),
|
||||
padding: explorerView.padding || settings.layoutMode === 'grid' ? 12 : undefined,
|
||||
padding: explorerView.padding ?? settings.layoutMode === 'grid' ? 12 : undefined,
|
||||
gap:
|
||||
explorerView.gap ||
|
||||
(settings.layoutMode === 'grid' ? explorerStore.gridGap : undefined),
|
||||
|
|
|
@ -2,13 +2,13 @@ import clsx from 'clsx';
|
|||
import { memo } from 'react';
|
||||
import { byteSize, getItemFilePath, getItemLocation, type ExplorerItem } from '@sd/client';
|
||||
|
||||
import { ViewItem } from '.';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { useQuickPreviewStore } from '../QuickPreview/store';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
import GridList from './GridList';
|
||||
import RenamableItemText from './RenamableItemText';
|
||||
import { ViewItem } from './ViewItem';
|
||||
|
||||
interface GridViewItemProps {
|
||||
data: ExplorerItem;
|
||||
|
|
File diff suppressed because it is too large
Load diff
993
interface/app/$libraryId/Explorer/View/ListView/index.tsx
Normal file
993
interface/app/$libraryId/Explorer/View/ListView/index.tsx
Normal file
|
@ -0,0 +1,993 @@
|
|||
import { CaretDown, CaretUp } from '@phosphor-icons/react';
|
||||
import {
|
||||
flexRender,
|
||||
VisibilityState,
|
||||
type ColumnSizingState,
|
||||
type Row
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import clsx from 'clsx';
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import BasicSticky from 'react-sticky-el';
|
||||
import { useKey, useMutationObserver, useWindowEventListener } from 'rooks';
|
||||
import useResizeObserver from 'use-resize-observer';
|
||||
import { type ExplorerItem } from '@sd/client';
|
||||
import { ContextMenu, Tooltip } from '@sd/ui';
|
||||
import { useIsTextTruncated } from '~/hooks';
|
||||
import { isNonEmptyObject } from '~/util';
|
||||
|
||||
import { useLayoutContext } from '../../../Layout/Context';
|
||||
import { useExplorerContext } from '../../Context';
|
||||
import { getQuickPreviewStore } from '../../QuickPreview/store';
|
||||
import {
|
||||
createOrdering,
|
||||
getOrderingDirection,
|
||||
isCut,
|
||||
orderingKey,
|
||||
useExplorerStore
|
||||
} from '../../store';
|
||||
import { uniqueId } from '../../util';
|
||||
import { useExplorerViewContext } from '../../ViewContext';
|
||||
import { ViewItem } from '../ViewItem';
|
||||
import { getRangeDirection, Range, useRanges } from './util/ranges';
|
||||
import { useTable } from './util/table';
|
||||
|
||||
interface ListViewItemProps {
|
||||
row: Row<ExplorerItem>;
|
||||
paddingX: number;
|
||||
// Props below are passed to trigger a rerender
|
||||
// TODO: Find a better solution
|
||||
columnSizing: ColumnSizingState;
|
||||
columnVisibility: VisibilityState;
|
||||
isCut: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
const ListViewItem = memo((props: ListViewItemProps) => {
|
||||
return (
|
||||
<ViewItem
|
||||
data={props.row.original}
|
||||
className="relative flex h-full items-center"
|
||||
style={{ paddingLeft: props.paddingX, paddingRight: props.paddingX }}
|
||||
>
|
||||
{props.row.getVisibleCells().map((cell) => (
|
||||
<div
|
||||
role="cell"
|
||||
key={cell.id}
|
||||
className={clsx(
|
||||
'table-cell shrink-0 truncate px-4 text-xs text-ink-dull',
|
||||
cell.column.columnDef.meta?.className
|
||||
)}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
))}
|
||||
</ViewItem>
|
||||
);
|
||||
});
|
||||
|
||||
const HeaderColumnName = ({ name }: { name: string }) => {
|
||||
const textRef = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
const isTruncated = useIsTextTruncated(textRef, name);
|
||||
|
||||
return (
|
||||
<div ref={textRef} className="truncate">
|
||||
{isTruncated ? (
|
||||
<Tooltip label={name}>
|
||||
<span className="truncate">{name}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span>{name}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ROW_HEIGHT = 45;
|
||||
|
||||
export default () => {
|
||||
const layout = useLayoutContext();
|
||||
const explorer = useExplorerContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
const explorerView = useExplorerViewContext();
|
||||
const settings = explorer.useSettingsSnapshot();
|
||||
|
||||
const tableRef = useRef<HTMLDivElement>(null);
|
||||
const tableHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const tableBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [sized, setSized] = useState(false);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const [top, setTop] = useState(0);
|
||||
const [listOffset, setListOffset] = useState(0);
|
||||
const [ranges, setRanges] = useState<Range[]>([]);
|
||||
const [isLeftMouseDown, setIsLeftMouseDown] = useState(false);
|
||||
|
||||
const { table } = useTable();
|
||||
const { columnVisibility, columnSizing } = table.getState();
|
||||
const { rows, rowsById } = table.getRowModel();
|
||||
|
||||
const { getRangeByIndex, getRangesByRow, getClosestRange } = useRanges({
|
||||
ranges,
|
||||
rows: rowsById
|
||||
});
|
||||
|
||||
const padding = {
|
||||
x: explorerView.padding?.x ?? 16,
|
||||
y: explorerView.padding?.y ?? 12
|
||||
};
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: explorer.count ?? rows.length,
|
||||
getScrollElement: useCallback(() => explorer.scrollRef.current, [explorer.scrollRef]),
|
||||
estimateSize: useCallback(() => ROW_HEIGHT, []),
|
||||
paddingStart: padding.y,
|
||||
paddingEnd: padding.y,
|
||||
scrollMargin: listOffset,
|
||||
overscan: explorer.overscan ?? 10
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
function handleRowClick(
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
row: Row<ExplorerItem>
|
||||
) {
|
||||
// Ensure mouse click is with left button
|
||||
if (e.button !== 0) return;
|
||||
|
||||
const rowIndex = row.index;
|
||||
const item = row.original;
|
||||
|
||||
if (explorer.allowMultiSelect) {
|
||||
if (e.shiftKey) {
|
||||
const range = getRangeByIndex(ranges.length - 1);
|
||||
|
||||
if (!range) {
|
||||
const items = [...Array(rowIndex + 1)].reduce<ExplorerItem[]>((items, _, i) => {
|
||||
const item = rows[i]?.original;
|
||||
if (item) return [...items, item];
|
||||
return items;
|
||||
}, []);
|
||||
|
||||
const [rangeStart] = items;
|
||||
|
||||
if (rangeStart) {
|
||||
setRanges([[uniqueId(rangeStart), uniqueId(item)]]);
|
||||
}
|
||||
|
||||
explorer.resetSelectedItems(items);
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = getRangeDirection(range.end.index, rowIndex);
|
||||
|
||||
if (!direction) return;
|
||||
|
||||
const changeDirection =
|
||||
!!range.direction &&
|
||||
range.direction !== direction &&
|
||||
(direction === 'down'
|
||||
? rowIndex > range.start.index
|
||||
: rowIndex < range.start.index);
|
||||
|
||||
let _ranges = ranges;
|
||||
|
||||
const [backRange, frontRange] = getRangesByRow(range.start);
|
||||
|
||||
if (backRange && frontRange) {
|
||||
[
|
||||
...Array(backRange.sorted.end.index - backRange.sorted.start.index + 1)
|
||||
].forEach((_, i) => {
|
||||
const index = backRange.sorted.start.index + i;
|
||||
|
||||
if (index === range.start.index) return;
|
||||
|
||||
const row = rows[index];
|
||||
|
||||
if (row) explorer.removeSelectedItem(row.original);
|
||||
});
|
||||
|
||||
_ranges = _ranges.filter((_, i) => i !== backRange.index);
|
||||
}
|
||||
|
||||
[
|
||||
...Array(Math.abs(range.end.index - rowIndex) + (changeDirection ? 1 : 0))
|
||||
].forEach((_, i) => {
|
||||
if (!range.direction || direction === range.direction) i += 1;
|
||||
|
||||
const index = range.end.index + (direction === 'down' ? i : -i);
|
||||
|
||||
const row = rows[index];
|
||||
|
||||
if (!row) return;
|
||||
|
||||
const item = row.original;
|
||||
|
||||
if (uniqueId(item) === uniqueId(range.start.original)) return;
|
||||
|
||||
if (
|
||||
!range.direction ||
|
||||
direction === range.direction ||
|
||||
(changeDirection &&
|
||||
(range.direction === 'down'
|
||||
? index < range.start.index
|
||||
: index > range.start.index))
|
||||
) {
|
||||
explorer.addSelectedItem(item);
|
||||
} else explorer.removeSelectedItem(item);
|
||||
});
|
||||
|
||||
let newRangeEnd = item;
|
||||
let removeRangeIndex: number | null = null;
|
||||
|
||||
for (let i = 0; i < _ranges.length - 1; i++) {
|
||||
const range = getRangeByIndex(i);
|
||||
|
||||
if (!range) continue;
|
||||
|
||||
if (
|
||||
rowIndex >= range.sorted.start.index &&
|
||||
rowIndex <= range.sorted.end.index
|
||||
) {
|
||||
const removableRowsCount = Math.abs(
|
||||
(direction === 'down'
|
||||
? range.sorted.end.index
|
||||
: range.sorted.start.index) - rowIndex
|
||||
);
|
||||
|
||||
[...Array(removableRowsCount)].forEach((_, i) => {
|
||||
i += 1;
|
||||
|
||||
const index = rowIndex + (direction === 'down' ? i : -i);
|
||||
|
||||
const row = rows[index];
|
||||
|
||||
if (row) explorer.removeSelectedItem(row.original);
|
||||
});
|
||||
|
||||
removeRangeIndex = i;
|
||||
break;
|
||||
} else if (direction === 'down' && rowIndex + 1 === range.sorted.start.index) {
|
||||
newRangeEnd = range.sorted.end.original;
|
||||
removeRangeIndex = i;
|
||||
break;
|
||||
} else if (direction === 'up' && rowIndex - 1 === range.sorted.end.index) {
|
||||
newRangeEnd = range.sorted.start.original;
|
||||
removeRangeIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (removeRangeIndex !== null) {
|
||||
_ranges = _ranges.filter((_, i) => i !== removeRangeIndex);
|
||||
}
|
||||
|
||||
setRanges([
|
||||
..._ranges.slice(0, _ranges.length - 1),
|
||||
[uniqueId(range.start.original), uniqueId(newRangeEnd)]
|
||||
]);
|
||||
} else if (e.metaKey) {
|
||||
if (explorer.selectedItems.has(item)) {
|
||||
explorer.removeSelectedItem(item);
|
||||
|
||||
const rowRanges = getRangesByRow(row);
|
||||
|
||||
const range = rowRanges[0] || rowRanges[1];
|
||||
|
||||
if (range) {
|
||||
const rangeStart = range.sorted.start.original;
|
||||
const rangeEnd = range.sorted.end.original;
|
||||
|
||||
if (rangeStart === rangeEnd) {
|
||||
const closestRange = getClosestRange(range.index);
|
||||
if (closestRange) {
|
||||
const _ranges = ranges.filter(
|
||||
(_, i) => i !== closestRange.index && i !== range.index
|
||||
);
|
||||
|
||||
const start = closestRange.sorted.start.original;
|
||||
const end = closestRange.sorted.end.original;
|
||||
|
||||
setRanges([
|
||||
..._ranges,
|
||||
[
|
||||
uniqueId(closestRange.direction === 'down' ? start : end),
|
||||
uniqueId(closestRange.direction === 'down' ? end : start)
|
||||
]
|
||||
]);
|
||||
} else {
|
||||
setRanges([]);
|
||||
}
|
||||
} else if (rangeStart === item || rangeEnd === item) {
|
||||
const _ranges = ranges.filter(
|
||||
(_, i) => i !== range.index && i !== rowRanges[1]?.index
|
||||
);
|
||||
|
||||
const start =
|
||||
rows[
|
||||
rangeStart === item
|
||||
? range.sorted.start.index + 1
|
||||
: range.sorted.end.index - 1
|
||||
]?.original;
|
||||
|
||||
if (start !== undefined) {
|
||||
const end = rangeStart === item ? rangeEnd : rangeStart;
|
||||
|
||||
setRanges([..._ranges, [uniqueId(start), uniqueId(end)]]);
|
||||
}
|
||||
} else {
|
||||
const rowBefore = rows[row.index - 1];
|
||||
const rowAfter = rows[row.index + 1];
|
||||
|
||||
if (rowBefore && rowAfter) {
|
||||
const firstRange = [
|
||||
uniqueId(rangeStart),
|
||||
uniqueId(rowBefore.original)
|
||||
] satisfies Range;
|
||||
|
||||
const secondRange = [
|
||||
uniqueId(rowAfter.original),
|
||||
uniqueId(rangeEnd)
|
||||
] satisfies Range;
|
||||
|
||||
const _ranges = ranges.filter(
|
||||
(_, i) => i !== range.index && i !== rowRanges[1]?.index
|
||||
);
|
||||
|
||||
setRanges([..._ranges, firstRange, secondRange]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
explorer.addSelectedItem(item);
|
||||
|
||||
const itemRange: Range = [uniqueId(item), uniqueId(item)];
|
||||
|
||||
const _ranges = [...ranges, itemRange];
|
||||
|
||||
const rangeDown = getClosestRange(_ranges.length - 1, {
|
||||
direction: 'down',
|
||||
maxRowDifference: 0,
|
||||
ranges: _ranges
|
||||
});
|
||||
|
||||
const rangeUp = getClosestRange(_ranges.length - 1, {
|
||||
direction: 'up',
|
||||
maxRowDifference: 0,
|
||||
ranges: _ranges
|
||||
});
|
||||
|
||||
if (rangeDown && rangeUp) {
|
||||
const _ranges = ranges.filter(
|
||||
(_, i) => i !== rangeDown.index && i !== rangeUp.index
|
||||
);
|
||||
|
||||
setRanges([
|
||||
..._ranges,
|
||||
[
|
||||
uniqueId(rangeUp.sorted.start.original),
|
||||
uniqueId(rangeDown.sorted.end.original)
|
||||
],
|
||||
itemRange
|
||||
]);
|
||||
} else if (rangeUp || rangeDown) {
|
||||
const closestRange = rangeDown || rangeUp;
|
||||
|
||||
if (closestRange) {
|
||||
const _ranges = ranges.filter((_, i) => i !== closestRange.index);
|
||||
|
||||
setRanges([
|
||||
..._ranges,
|
||||
[
|
||||
uniqueId(item),
|
||||
uniqueId(
|
||||
closestRange.direction === 'down'
|
||||
? closestRange.sorted.end.original
|
||||
: closestRange.sorted.start.original
|
||||
)
|
||||
]
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
setRanges([...ranges, itemRange]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (explorer.isItemSelected(item)) return;
|
||||
|
||||
explorer.resetSelectedItems([item]);
|
||||
const hash = uniqueId(item);
|
||||
setRanges([[hash, hash]]);
|
||||
}
|
||||
} else {
|
||||
explorer.resetSelectedItems([item]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRowContextMenu(row: Row<ExplorerItem>) {
|
||||
if (explorerView.contextMenu === undefined) return;
|
||||
|
||||
const item = row.original;
|
||||
|
||||
if (!explorer.isItemSelected(item)) {
|
||||
explorer.resetSelectedItems([item]);
|
||||
const hash = uniqueId(item);
|
||||
setRanges([[hash, hash]]);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset ranges
|
||||
useEffect(() => setRanges([]), [explorer.items]);
|
||||
|
||||
// Measure initial column widths
|
||||
useEffect(() => {
|
||||
if (
|
||||
!tableRef.current ||
|
||||
sized ||
|
||||
!isNonEmptyObject(columnSizing) ||
|
||||
!isNonEmptyObject(columnVisibility)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sizing = table
|
||||
.getVisibleLeafColumns()
|
||||
.reduce(
|
||||
(sizing, column) => ({ ...sizing, [column.id]: column.getSize() }),
|
||||
{} as ColumnSizingState
|
||||
);
|
||||
|
||||
const tableWidth = tableRef.current.offsetWidth;
|
||||
const columnsWidth = Object.values(sizing).reduce((a, b) => a + b, 0) + padding.x * 2;
|
||||
|
||||
if (columnsWidth < tableWidth) {
|
||||
const nameWidth = (sizing.name ?? 0) + (tableWidth - columnsWidth);
|
||||
table.setColumnSizing({ ...sizing, name: nameWidth });
|
||||
setLocked(true);
|
||||
} else if (columnsWidth > tableWidth) {
|
||||
const nameColSize = sizing.name ?? 0;
|
||||
const minNameColSize = table.getColumn('name')?.columnDef.minSize;
|
||||
|
||||
const difference = columnsWidth - tableWidth;
|
||||
|
||||
if (minNameColSize !== undefined && nameColSize - difference >= minNameColSize) {
|
||||
table.setColumnSizing({ ...sizing, name: nameColSize - difference });
|
||||
setLocked(true);
|
||||
}
|
||||
} else if (columnsWidth === tableWidth) {
|
||||
setLocked(true);
|
||||
}
|
||||
|
||||
setSized(true);
|
||||
}, [columnSizing, columnVisibility, padding.x, sized, table]);
|
||||
|
||||
// Load more items
|
||||
useEffect(() => {
|
||||
if (!explorer.loadMore) return;
|
||||
|
||||
const lastRow = virtualRows[virtualRows.length - 1];
|
||||
if (!lastRow) return;
|
||||
|
||||
const loadMoreFromRow = Math.ceil(rows.length * 0.75);
|
||||
|
||||
if (lastRow.index >= loadMoreFromRow - 1) explorer.loadMore.call(undefined);
|
||||
}, [virtualRows, rows.length, explorer.loadMore]);
|
||||
|
||||
// Sync scroll
|
||||
useEffect(() => {
|
||||
const table = tableRef.current;
|
||||
const header = tableHeaderRef.current;
|
||||
const body = tableBodyRef.current;
|
||||
|
||||
if (!table || !header || !body) return;
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (Math.abs(event.deltaX) < Math.abs(event.deltaY)) return;
|
||||
event.preventDefault();
|
||||
header.scrollLeft += event.deltaX;
|
||||
body.scrollLeft += event.deltaX;
|
||||
};
|
||||
|
||||
const handleScroll = (element: HTMLDivElement) => {
|
||||
if (isLeftMouseDown) return;
|
||||
// Sorting sometimes resets scrollLeft
|
||||
// so we reset it here in case it does
|
||||
// to keep the scroll in sync
|
||||
// TODO: Find a better solution
|
||||
header.scrollLeft = element.scrollLeft;
|
||||
body.scrollLeft = element.scrollLeft;
|
||||
};
|
||||
|
||||
table.addEventListener('wheel', handleWheel);
|
||||
header.addEventListener('scroll', () => handleScroll(header));
|
||||
body.addEventListener('scroll', () => handleScroll(body));
|
||||
|
||||
return () => {
|
||||
table.removeEventListener('wheel', handleWheel);
|
||||
header.addEventListener('scroll', () => handleScroll(header));
|
||||
body.addEventListener('scroll', () => handleScroll(body));
|
||||
};
|
||||
}, [sized, isLeftMouseDown]);
|
||||
|
||||
// Handle key selection
|
||||
useKey(['ArrowUp', 'ArrowDown', 'Escape'], (e) => {
|
||||
if (!explorerView.selectable) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const range = getRangeByIndex(ranges.length - 1);
|
||||
|
||||
if (e.key === 'ArrowDown' && explorer.selectedItems.size === 0) {
|
||||
const item = rows[0]?.original;
|
||||
if (item) {
|
||||
explorer.addSelectedItem(item);
|
||||
setRanges([[uniqueId(item), uniqueId(item)]]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!range) return;
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
explorer.resetSelectedItems([]);
|
||||
setRanges([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const keyDirection = e.key === 'ArrowDown' ? 'down' : 'up';
|
||||
|
||||
const nextRow = rows[range.end.index + (keyDirection === 'up' ? -1 : 1)];
|
||||
|
||||
if (!nextRow) return;
|
||||
|
||||
const item = nextRow.original;
|
||||
|
||||
if (explorer.allowMultiSelect) {
|
||||
if (e.shiftKey && !getQuickPreviewStore().open) {
|
||||
const direction = range.direction || keyDirection;
|
||||
|
||||
const [backRange, frontRange] = getRangesByRow(range.start);
|
||||
|
||||
if (
|
||||
range.direction
|
||||
? keyDirection !== range.direction
|
||||
: backRange?.direction &&
|
||||
(backRange.sorted.start.index === frontRange?.sorted.start.index ||
|
||||
backRange.sorted.end.index === frontRange?.sorted.end.index)
|
||||
) {
|
||||
explorer.removeSelectedItem(range.end.original);
|
||||
|
||||
if (backRange && frontRange) {
|
||||
let _ranges = [...ranges];
|
||||
|
||||
_ranges[backRange.index] = [
|
||||
uniqueId(
|
||||
backRange.direction !== keyDirection
|
||||
? backRange.start.original
|
||||
: nextRow.original
|
||||
),
|
||||
uniqueId(
|
||||
backRange.direction !== keyDirection
|
||||
? nextRow.original
|
||||
: backRange.end.original
|
||||
)
|
||||
];
|
||||
|
||||
if (
|
||||
nextRow.index === backRange.start.index ||
|
||||
nextRow.index === backRange.end.index
|
||||
) {
|
||||
_ranges = _ranges.filter((_, i) => i !== frontRange.index);
|
||||
} else {
|
||||
_ranges[frontRange.index] =
|
||||
frontRange.start.index === frontRange.end.index
|
||||
? [uniqueId(nextRow.original), uniqueId(nextRow.original)]
|
||||
: [
|
||||
uniqueId(frontRange.start.original),
|
||||
uniqueId(nextRow.original)
|
||||
];
|
||||
}
|
||||
|
||||
setRanges(_ranges);
|
||||
} else {
|
||||
setRanges([
|
||||
...ranges.slice(0, ranges.length - 1),
|
||||
[uniqueId(range.start.original), uniqueId(nextRow.original)]
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
explorer.addSelectedItem(item);
|
||||
|
||||
let rangeEndRow = nextRow;
|
||||
|
||||
const closestRange = getClosestRange(range.index, {
|
||||
maxRowDifference: 1,
|
||||
direction
|
||||
});
|
||||
|
||||
if (closestRange) {
|
||||
rangeEndRow =
|
||||
direction === 'down'
|
||||
? closestRange.sorted.end
|
||||
: closestRange.sorted.start;
|
||||
}
|
||||
|
||||
if (backRange && frontRange) {
|
||||
let _ranges = [...ranges];
|
||||
|
||||
const backRangeStart = backRange.start.original;
|
||||
|
||||
const backRangeEnd =
|
||||
rangeEndRow.index < backRange.sorted.start.index ||
|
||||
rangeEndRow.index > backRange.sorted.end.index
|
||||
? rangeEndRow.original
|
||||
: backRange.end.original;
|
||||
|
||||
_ranges[backRange.index] = [
|
||||
uniqueId(backRangeStart),
|
||||
uniqueId(backRangeEnd)
|
||||
];
|
||||
|
||||
if (
|
||||
backRange.direction !== direction &&
|
||||
(rangeEndRow.original === backRangeStart ||
|
||||
rangeEndRow.original === backRangeEnd)
|
||||
) {
|
||||
_ranges[backRange.index] =
|
||||
rangeEndRow.original === backRangeStart
|
||||
? [uniqueId(backRangeEnd), uniqueId(backRangeStart)]
|
||||
: [uniqueId(backRangeStart), uniqueId(backRangeEnd)];
|
||||
}
|
||||
|
||||
_ranges[frontRange.index] = [
|
||||
uniqueId(frontRange.start.original),
|
||||
uniqueId(rangeEndRow.original)
|
||||
];
|
||||
|
||||
if (closestRange) {
|
||||
_ranges = _ranges.filter((_, i) => i !== closestRange.index);
|
||||
}
|
||||
|
||||
setRanges(_ranges);
|
||||
} else {
|
||||
const _ranges = closestRange
|
||||
? ranges.filter((_, i) => i !== closestRange.index && i !== range.index)
|
||||
: ranges;
|
||||
|
||||
setRanges([
|
||||
..._ranges.slice(0, _ranges.length - 1),
|
||||
[uniqueId(range.start.original), uniqueId(rangeEndRow.original)]
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
explorer.resetSelectedItems([item]);
|
||||
const hash = uniqueId(item);
|
||||
setRanges([[hash, hash]]);
|
||||
}
|
||||
} else explorer.resetSelectedItems([item]);
|
||||
|
||||
if (explorer.scrollRef.current) {
|
||||
const tableBodyRect = tableBodyRef.current?.getBoundingClientRect();
|
||||
const scrollRect = explorer.scrollRef.current.getBoundingClientRect();
|
||||
|
||||
const paddingTop = parseInt(getComputedStyle(explorer.scrollRef.current).paddingTop);
|
||||
|
||||
const top =
|
||||
(explorerView.top ? paddingTop + explorerView.top : paddingTop) +
|
||||
scrollRect.top +
|
||||
(explorer.scrollRef.current.scrollTop > listOffset ? 36 : 0);
|
||||
|
||||
const rowTop =
|
||||
nextRow.index * ROW_HEIGHT +
|
||||
rowVirtualizer.options.paddingStart +
|
||||
(tableBodyRect?.top || 0) +
|
||||
scrollRect.top;
|
||||
|
||||
const rowBottom = rowTop + ROW_HEIGHT;
|
||||
|
||||
if (rowTop < top) {
|
||||
const scrollBy = rowTop - top - (nextRow.index === 0 ? padding.y : 0);
|
||||
|
||||
explorer.scrollRef.current.scrollBy({
|
||||
top: scrollBy,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
} else if (rowBottom > scrollRect.bottom) {
|
||||
const scrollBy =
|
||||
rowBottom -
|
||||
scrollRect.height +
|
||||
(nextRow.index === rows.length - 1 ? padding.y : 0);
|
||||
|
||||
explorer.scrollRef.current.scrollBy({
|
||||
top: scrollBy,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset resizing cursor
|
||||
useWindowEventListener('mouseup', () => {
|
||||
// We timeout the reset so the col sorting
|
||||
// doesn't get triggered on mouse up
|
||||
setTimeout(() => {
|
||||
setResizing(false);
|
||||
setIsLeftMouseDown(false);
|
||||
if (layout.ref.current) layout.ref.current.style.cursor = '';
|
||||
});
|
||||
});
|
||||
|
||||
// Handle table resize
|
||||
useResizeObserver({
|
||||
ref: tableRef,
|
||||
onResize: ({ width }) => {
|
||||
if (!width) return;
|
||||
|
||||
const sizing = table
|
||||
.getVisibleLeafColumns()
|
||||
.reduce(
|
||||
(sizing, column) => ({ ...sizing, [column.id]: column.getSize() }),
|
||||
{} as ColumnSizingState
|
||||
);
|
||||
|
||||
const columnsWidth = Object.values(sizing).reduce((a, b) => a + b, 0) + padding.x * 2;
|
||||
|
||||
if (locked) {
|
||||
const newNameSize = (sizing.name ?? 0) + (width - columnsWidth);
|
||||
const minNameColSize = table.getColumn('name')?.columnDef.minSize;
|
||||
|
||||
if (minNameColSize !== undefined && newNameSize < minNameColSize) return;
|
||||
|
||||
table.setColumnSizing({
|
||||
...columnSizing,
|
||||
name: newNameSize
|
||||
});
|
||||
} else if (Math.abs(width - columnsWidth) < 15) {
|
||||
setLocked(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set header position and list offset
|
||||
useMutationObserver(explorer.scrollRef, () => {
|
||||
const view = explorerView.ref.current;
|
||||
const scroll = explorer.scrollRef.current;
|
||||
if (!view || !scroll) return;
|
||||
setTop(
|
||||
explorerView.top ??
|
||||
parseInt(getComputedStyle(scroll).paddingTop) + scroll.getBoundingClientRect().top
|
||||
);
|
||||
setListOffset(tableRef.current?.offsetTop ?? 0);
|
||||
});
|
||||
|
||||
// Set list offset
|
||||
useLayoutEffect(() => setListOffset(tableRef.current?.offsetTop ?? 0), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tableRef}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsLeftMouseDown(true);
|
||||
}}
|
||||
>
|
||||
{sized && (
|
||||
<>
|
||||
<BasicSticky
|
||||
scrollElement={explorer.scrollRef.current ?? undefined}
|
||||
stickyStyle={{ top, zIndex: 10 }}
|
||||
topOffset={-top}
|
||||
// Without this the width of the element doesn't get updated
|
||||
// when the inspector is toggled
|
||||
positionRecheckInterval={100}
|
||||
>
|
||||
<ContextMenu.Root
|
||||
trigger={
|
||||
<div
|
||||
ref={tableHeaderRef}
|
||||
className={clsx(
|
||||
'top-bar-blur !border-sidebar-divider bg-app/90',
|
||||
explorerView.listViewOptions?.hideHeaderBorder
|
||||
? 'border-b'
|
||||
: 'border-y',
|
||||
// Prevent drag scroll
|
||||
isLeftMouseDown
|
||||
? 'overflow-hidden'
|
||||
: 'no-scrollbar overflow-x-auto overscroll-x-none'
|
||||
)}
|
||||
>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<div key={headerGroup.id} className="flex w-fit">
|
||||
{headerGroup.headers.map((header, i) => {
|
||||
const size = header.column.getSize();
|
||||
|
||||
const orderingDirection =
|
||||
settings.order &&
|
||||
orderingKey(settings.order) === header.id
|
||||
? getOrderingDirection(settings.order)
|
||||
: null;
|
||||
|
||||
const cellContent = flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={clsx(
|
||||
'relative flex items-center justify-between gap-3 px-4 py-2 text-xs first:pl-[83px]',
|
||||
orderingDirection !== null
|
||||
? 'text-ink'
|
||||
: 'text-ink-dull'
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
i === 0 ||
|
||||
i === headerGroup.headers.length - 1
|
||||
? size + padding.x
|
||||
: size
|
||||
}}
|
||||
onClick={() => {
|
||||
if (resizing) return;
|
||||
if (header.column.getCanSort()) {
|
||||
explorer.settingsStore.order =
|
||||
createOrdering(
|
||||
header.id,
|
||||
orderingDirection === 'Asc'
|
||||
? 'Desc'
|
||||
: 'Asc'
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<>
|
||||
{typeof cellContent === 'string' ? (
|
||||
<HeaderColumnName
|
||||
name={cellContent}
|
||||
/>
|
||||
) : (
|
||||
cellContent
|
||||
)}
|
||||
|
||||
{orderingDirection === 'Asc' && (
|
||||
<CaretUp className="shrink-0 text-ink-faint" />
|
||||
)}
|
||||
|
||||
{orderingDirection === 'Desc' && (
|
||||
<CaretDown className="shrink-0 text-ink-faint" />
|
||||
)}
|
||||
|
||||
<div
|
||||
onMouseDown={(e) => {
|
||||
setResizing(true);
|
||||
setLocked(false);
|
||||
|
||||
header.getResizeHandler()(
|
||||
e
|
||||
);
|
||||
|
||||
if (layout.ref.current) {
|
||||
layout.ref.current.style.cursor =
|
||||
'col-resize';
|
||||
}
|
||||
}}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className="absolute right-0 h-[70%] w-2 cursor-col-resize border-r border-sidebar-divider"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{table.getAllLeafColumns().map((column) => {
|
||||
if (column.id === 'name') return null;
|
||||
return (
|
||||
<ContextMenu.CheckboxItem
|
||||
key={column.id}
|
||||
checked={column.getIsVisible()}
|
||||
onSelect={column.getToggleVisibilityHandler()}
|
||||
label={
|
||||
typeof column.columnDef.header === 'string'
|
||||
? column.columnDef.header
|
||||
: column.id
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ContextMenu.Root>
|
||||
</BasicSticky>
|
||||
|
||||
<div
|
||||
ref={tableBodyRef}
|
||||
className={clsx(
|
||||
// Prevent drag scroll
|
||||
isLeftMouseDown
|
||||
? 'overflow-hidden'
|
||||
: 'no-scrollbar overflow-x-auto overscroll-x-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
const selected = explorer.isItemSelected(row.original);
|
||||
const cut = isCut(row.original, explorerStore.cutCopyState);
|
||||
|
||||
const previousRow = rows[virtualRow.index - 1];
|
||||
const nextRow = rows[virtualRow.index + 1];
|
||||
|
||||
const selectedPrior =
|
||||
previousRow && explorer.isItemSelected(previousRow.original);
|
||||
|
||||
const selectedNext =
|
||||
nextRow && explorer.isItemSelected(nextRow.original);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className="absolute left-0 top-0 min-w-full"
|
||||
style={{
|
||||
height: virtualRow.size,
|
||||
transform: `translateY(${
|
||||
virtualRow.start -
|
||||
rowVirtualizer.options.scrollMargin
|
||||
}px)`
|
||||
}}
|
||||
onMouseDown={(e) => handleRowClick(e, row)}
|
||||
onContextMenu={() => handleRowContextMenu(row)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute inset-0 rounded-md border',
|
||||
virtualRow.index % 2 === 0 && 'bg-app-darkBox',
|
||||
selected
|
||||
? 'border-accent !bg-accent/10'
|
||||
: 'border-transparent',
|
||||
selected &&
|
||||
selectedPrior &&
|
||||
'rounded-t-none border-t-0 border-t-transparent',
|
||||
selected &&
|
||||
selectedNext &&
|
||||
'rounded-b-none border-b-0 border-b-transparent'
|
||||
)}
|
||||
style={{ right: padding.x, left: padding.x }}
|
||||
>
|
||||
{selectedPrior && (
|
||||
<div className="absolute inset-x-3 top-0 h-px bg-accent/10" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ListViewItem
|
||||
row={row}
|
||||
paddingX={padding.x}
|
||||
columnSizing={columnSizing}
|
||||
columnVisibility={columnVisibility}
|
||||
isCut={cut}
|
||||
isSelected={selected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
158
interface/app/$libraryId/Explorer/View/ListView/util/ranges.tsx
Normal file
158
interface/app/$libraryId/Explorer/View/ListView/util/ranges.tsx
Normal file
|
@ -0,0 +1,158 @@
|
|||
import { Row } from '@tanstack/react-table';
|
||||
import { useCallback } from 'react';
|
||||
import { type ExplorerItem } from '@sd/client';
|
||||
|
||||
export function getRangeDirection(start: number, end: number) {
|
||||
return start < end ? ('down' as const) : start > end ? ('up' as const) : null;
|
||||
}
|
||||
|
||||
export type Range = [string, string];
|
||||
|
||||
interface UseRangesProps {
|
||||
ranges: Range[];
|
||||
rows: Record<string, Row<ExplorerItem>>;
|
||||
}
|
||||
|
||||
export const useRanges = ({ ranges, rows }: UseRangesProps) => {
|
||||
const getRangeRows = useCallback(
|
||||
(range: Range) => {
|
||||
const rangeRows = range
|
||||
.map((id) => rows[id])
|
||||
.filter((row): row is Row<ExplorerItem> => Boolean(row));
|
||||
|
||||
const [start, end] = rangeRows;
|
||||
|
||||
const [sortedStart, sortedEnd] = [...rangeRows].sort((a, b) => a.index - b.index);
|
||||
|
||||
if (!start || !end || !sortedStart || !sortedEnd) return;
|
||||
|
||||
return { start, end, sorted: { start: sortedStart, end: sortedEnd } };
|
||||
},
|
||||
[rows]
|
||||
);
|
||||
|
||||
const getRangeByIndex = useCallback(
|
||||
(index: number) => {
|
||||
const range = ranges[index];
|
||||
|
||||
if (!range) return;
|
||||
|
||||
const rangeRows = getRangeRows(range);
|
||||
|
||||
if (!rangeRows) return;
|
||||
|
||||
const direction = getRangeDirection(rangeRows.start.index, rangeRows.end.index);
|
||||
|
||||
return { ...rangeRows, direction, index };
|
||||
},
|
||||
[getRangeRows, ranges]
|
||||
);
|
||||
|
||||
const getRangesByRow = useCallback(
|
||||
({ index }: Row<ExplorerItem>) => {
|
||||
const _ranges = ranges.reduce<NonNullable<ReturnType<typeof getRangeByIndex>>[]>(
|
||||
(ranges, range, i) => {
|
||||
const rangeRows = getRangeRows(range);
|
||||
|
||||
if (!rangeRows) return ranges;
|
||||
|
||||
if (
|
||||
index >= rangeRows.sorted.start.index &&
|
||||
index <= rangeRows.sorted.end.index
|
||||
) {
|
||||
const range = getRangeByIndex(i);
|
||||
return range ? [...ranges, range] : ranges;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return _ranges;
|
||||
},
|
||||
[getRangeByIndex, getRangeRows, ranges]
|
||||
);
|
||||
|
||||
const sortRanges = useCallback(
|
||||
(ranges: Range[]) => {
|
||||
return ranges
|
||||
.map((range, i) => {
|
||||
const rows = getRangeRows(range);
|
||||
|
||||
if (!rows) return;
|
||||
|
||||
return {
|
||||
index: i,
|
||||
...rows
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(
|
||||
range
|
||||
): range is NonNullable<ReturnType<typeof getRangeRows>> & { index: number } =>
|
||||
Boolean(range)
|
||||
)
|
||||
.sort((a, b) => a.sorted.start.index - b.sorted.start.index);
|
||||
},
|
||||
[getRangeRows]
|
||||
);
|
||||
|
||||
const getClosestRange = useCallback(
|
||||
(
|
||||
rangeIndex: number,
|
||||
options: {
|
||||
direction?: 'up' | 'down';
|
||||
maxRowDifference?: number;
|
||||
ranges?: Range[];
|
||||
} = {}
|
||||
) => {
|
||||
const range = getRangeByIndex(rangeIndex);
|
||||
|
||||
let _ranges = sortRanges(options.ranges || ranges);
|
||||
|
||||
if (range) {
|
||||
_ranges = _ranges.filter(
|
||||
(_range) =>
|
||||
range.index === _range.index ||
|
||||
range.sorted.start.index < _range.sorted.start.index ||
|
||||
range.sorted.end.index > _range.sorted.end.index
|
||||
);
|
||||
}
|
||||
|
||||
const targetRangeIndex = _ranges.findIndex(({ index }) => rangeIndex === index);
|
||||
|
||||
const targetRange = _ranges[targetRangeIndex];
|
||||
|
||||
if (!targetRange) return;
|
||||
|
||||
const closestRange =
|
||||
options.direction === 'down'
|
||||
? _ranges[targetRangeIndex + 1]
|
||||
: options.direction === 'up'
|
||||
? _ranges[targetRangeIndex - 1]
|
||||
: _ranges[targetRangeIndex + 1] || _ranges[targetRangeIndex - 1];
|
||||
|
||||
if (!closestRange) return;
|
||||
|
||||
const direction = options.direction || (_ranges[targetRangeIndex + 1] ? 'down' : 'up');
|
||||
|
||||
const rowDifference =
|
||||
direction === 'down'
|
||||
? closestRange.sorted.start.index - 1 - targetRange.sorted.end.index
|
||||
: targetRange.sorted.start.index - (closestRange.sorted.end.index + 1);
|
||||
|
||||
if (options.maxRowDifference !== undefined && rowDifference > options.maxRowDifference)
|
||||
return;
|
||||
|
||||
return {
|
||||
...closestRange,
|
||||
direction,
|
||||
rowDifference
|
||||
};
|
||||
},
|
||||
[getRangeByIndex, ranges, sortRanges]
|
||||
);
|
||||
|
||||
return { getRangeByIndex, getRangesByRow, getClosestRange };
|
||||
};
|
184
interface/app/$libraryId/Explorer/View/ListView/util/table.tsx
Normal file
184
interface/app/$libraryId/Explorer/View/ListView/util/table.tsx
Normal file
|
@ -0,0 +1,184 @@
|
|||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type ColumnSizingState,
|
||||
type VisibilityState
|
||||
} from '@tanstack/react-table';
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { stringify } from 'uuid';
|
||||
import {
|
||||
byteSize,
|
||||
getExplorerItemData,
|
||||
getItemFilePath,
|
||||
getItemObject,
|
||||
type ExplorerItem
|
||||
} from '@sd/client';
|
||||
import { isNonEmptyObject } from '~/util';
|
||||
|
||||
import { useExplorerContext } from '../../../Context';
|
||||
import { FileThumb } from '../../../FilePath/Thumb';
|
||||
import { InfoPill } from '../../../Inspector';
|
||||
import { useQuickPreviewStore } from '../../../QuickPreview/store';
|
||||
import { isCut, useExplorerStore } from '../../../store';
|
||||
import { uniqueId } from '../../../util';
|
||||
import RenamableItemText from '../../RenamableItemText';
|
||||
|
||||
export const useTable = () => {
|
||||
const explorer = useExplorerContext();
|
||||
const explorerStore = useExplorerStore();
|
||||
const quickPreviewStore = useQuickPreviewStore();
|
||||
|
||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
|
||||
const columns = useMemo<ColumnDef<ExplorerItem>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'name',
|
||||
header: 'Name',
|
||||
minSize: 200,
|
||||
maxSize: undefined,
|
||||
accessorFn: (file) => getExplorerItemData(file).fullName,
|
||||
cell: (cell) => {
|
||||
const item = cell.row.original;
|
||||
|
||||
const selected = explorer.selectedItems.has(item);
|
||||
const cut = isCut(item, explorerStore.cutCopyState);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<FileThumb
|
||||
data={item}
|
||||
size={35}
|
||||
blackBars
|
||||
className={clsx('mr-2.5', cut && 'opacity-60')}
|
||||
/>
|
||||
|
||||
<RenamableItemText
|
||||
allowHighlight={false}
|
||||
item={item}
|
||||
selected={selected}
|
||||
disabled={
|
||||
!selected ||
|
||||
explorer.selectedItems.size > 1 ||
|
||||
quickPreviewStore.open
|
||||
}
|
||||
style={{ maxHeight: 36 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'kind',
|
||||
header: 'Type',
|
||||
enableSorting: false,
|
||||
accessorFn: (file) => getExplorerItemData(file).kind,
|
||||
cell: (cell) => (
|
||||
<InfoPill className="bg-app-button/50">
|
||||
{getExplorerItemData(cell.row.original).kind}
|
||||
</InfoPill>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'sizeInBytes',
|
||||
header: 'Size',
|
||||
accessorFn: (file) => {
|
||||
const file_path = getItemFilePath(file);
|
||||
if (!file_path || !file_path.size_in_bytes_bytes) return;
|
||||
|
||||
return byteSize(file_path.size_in_bytes_bytes);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dateCreated',
|
||||
header: 'Date Created',
|
||||
accessorFn: (file) => dayjs(file.item.date_created).format('MMM Do YYYY')
|
||||
},
|
||||
{
|
||||
id: 'dateModified',
|
||||
header: 'Date Modified',
|
||||
accessorFn: (file) =>
|
||||
dayjs(getItemFilePath(file)?.date_modified).format('MMM Do YYYY')
|
||||
},
|
||||
{
|
||||
id: 'dateIndexed',
|
||||
header: 'Date Indexed',
|
||||
accessorFn: (file) => {
|
||||
const item = getItemFilePath(file);
|
||||
return dayjs(
|
||||
(item && 'date_indexed' in item && item.date_indexed) || null
|
||||
).format('MMM Do YYYY');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'dateAccessed',
|
||||
header: 'Date Accessed',
|
||||
accessorFn: (file) =>
|
||||
getItemObject(file)?.date_accessed &&
|
||||
dayjs(getItemObject(file)?.date_accessed).format('MMM Do YYYY')
|
||||
},
|
||||
{
|
||||
id: 'contentId',
|
||||
header: 'Content ID',
|
||||
enableSorting: false,
|
||||
accessorFn: (file) => getExplorerItemData(file).casId
|
||||
},
|
||||
{
|
||||
id: 'objectId',
|
||||
header: 'Object ID',
|
||||
enableSorting: false,
|
||||
accessorFn: (file) => {
|
||||
const value = getItemObject(file)?.pub_id;
|
||||
if (!value) return null;
|
||||
return stringify(value);
|
||||
}
|
||||
}
|
||||
],
|
||||
[explorer.selectedItems, explorerStore.cutCopyState, quickPreviewStore.open]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: useMemo(() => explorer.items ?? [], [explorer.items]),
|
||||
columns,
|
||||
defaultColumn: { minSize: 100, maxSize: 250 },
|
||||
state: { columnSizing, columnVisibility },
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
columnResizeMode: 'onChange',
|
||||
getCoreRowModel: useMemo(() => getCoreRowModel(), []),
|
||||
getRowId: uniqueId
|
||||
});
|
||||
|
||||
// Initialize column visibility from explorer settings
|
||||
useEffect(() => {
|
||||
if (isNonEmptyObject(columnVisibility)) return;
|
||||
table.setColumnVisibility(explorer.settingsStore.colVisibility);
|
||||
}, [columnVisibility, explorer.settingsStore.colVisibility, table]);
|
||||
|
||||
// Update column visibility in explorer settings
|
||||
// We don't update directly because it takes too long to get the updated values
|
||||
useEffect(() => {
|
||||
if (!isNonEmptyObject(columnVisibility)) return;
|
||||
explorer.settingsStore.colVisibility =
|
||||
columnVisibility as typeof explorer.settingsStore.colVisibility;
|
||||
}, [columnVisibility, explorer]);
|
||||
|
||||
// Initialize column sizes from explorer settings
|
||||
useEffect(() => {
|
||||
if (isNonEmptyObject(columnSizing)) return;
|
||||
table.setColumnSizing(explorer.settingsStore.colSizes);
|
||||
}, [columnSizing, explorer.settingsStore.colSizes, table]);
|
||||
|
||||
// Update column sizing in explorer settings
|
||||
// We don't update directly because it takes too long to get the updated values
|
||||
useEffect(() => {
|
||||
if (!isNonEmptyObject(columnSizing)) return;
|
||||
explorer.settingsStore.colSizes = columnSizing as typeof explorer.settingsStore.colSizes;
|
||||
}, [columnSizing, explorer]);
|
||||
|
||||
return { table };
|
||||
};
|
|
@ -4,11 +4,11 @@ import { memo } from 'react';
|
|||
import { ExplorerItem } from '@sd/client';
|
||||
import { Button } from '@sd/ui';
|
||||
|
||||
import { ViewItem } from '.';
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { FileThumb } from '../FilePath/Thumb';
|
||||
import { getQuickPreviewStore } from '../QuickPreview/store';
|
||||
import GridList from './GridList';
|
||||
import { ViewItem } from './ViewItem';
|
||||
|
||||
interface MediaViewItemProps {
|
||||
data: ExplorerItem;
|
||||
|
|
175
interface/app/$libraryId/Explorer/View/ViewItem.tsx
Normal file
175
interface/app/$libraryId/Explorer/View/ViewItem.tsx
Normal file
|
@ -0,0 +1,175 @@
|
|||
import { memo, useCallback, type HTMLAttributes, type PropsWithChildren } from 'react';
|
||||
import { createSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
isPath,
|
||||
useLibraryContext,
|
||||
useLibraryMutation,
|
||||
type ExplorerItem,
|
||||
type FilePath,
|
||||
type Location,
|
||||
type NonIndexedPathItem
|
||||
} from '@sd/client';
|
||||
import { ContextMenu, toast } from '@sd/ui';
|
||||
import { isNonEmpty } from '~/util';
|
||||
import { usePlatform } from '~/util/Platform';
|
||||
|
||||
import { useExplorerContext } from '../Context';
|
||||
import { getQuickPreviewStore } from '../QuickPreview/store';
|
||||
import { uniqueId } from '../util';
|
||||
import { useExplorerViewContext } from '../ViewContext';
|
||||
|
||||
export const useViewItemDoubleClick = () => {
|
||||
const navigate = useNavigate();
|
||||
const explorer = useExplorerContext();
|
||||
const { library } = useLibraryContext();
|
||||
const { openFilePaths } = usePlatform();
|
||||
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
|
||||
const doubleClick = useCallback(
|
||||
async (item?: ExplorerItem) => {
|
||||
const selectedItems = [...explorer.selectedItems];
|
||||
|
||||
if (!isNonEmpty(selectedItems)) return;
|
||||
|
||||
let itemIndex = 0;
|
||||
const items = selectedItems.reduce(
|
||||
(items, selectedItem, i) => {
|
||||
const sameAsClicked = item && uniqueId(item) === uniqueId(selectedItem);
|
||||
|
||||
if (sameAsClicked) itemIndex = i;
|
||||
|
||||
switch (selectedItem.type) {
|
||||
case 'Location': {
|
||||
items.locations.splice(sameAsClicked ? 0 : -1, 0, selectedItem.item);
|
||||
break;
|
||||
}
|
||||
case 'NonIndexedPath': {
|
||||
items.non_indexed.splice(sameAsClicked ? 0 : -1, 0, selectedItem.item);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
for (const filePath of selectedItem.type === 'Path'
|
||||
? [selectedItem.item]
|
||||
: selectedItem.item.file_paths) {
|
||||
if (isPath(selectedItem) && selectedItem.item.is_dir) {
|
||||
items.dirs.splice(sameAsClicked ? 0 : -1, 0, filePath);
|
||||
} else {
|
||||
items.paths.splice(sameAsClicked ? 0 : -1, 0, filePath);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
{
|
||||
dirs: [],
|
||||
paths: [],
|
||||
locations: [],
|
||||
non_indexed: []
|
||||
} as {
|
||||
dirs: FilePath[];
|
||||
paths: FilePath[];
|
||||
locations: Location[];
|
||||
non_indexed: NonIndexedPathItem[];
|
||||
}
|
||||
);
|
||||
|
||||
if (items.paths.length > 0) {
|
||||
if (explorer.settingsStore.openOnDoubleClick === 'openFile' && openFilePaths) {
|
||||
updateAccessTime
|
||||
.mutateAsync(items.paths.map(({ object_id }) => object_id!).filter(Boolean))
|
||||
.catch(console.error);
|
||||
|
||||
try {
|
||||
await openFilePaths(
|
||||
library.uuid,
|
||||
items.paths.map(({ id }) => id)
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error({ title: 'Failed to open file', body: `Error: ${error}.` });
|
||||
}
|
||||
} else if (item && explorer.settingsStore.openOnDoubleClick === 'quickPreview') {
|
||||
if (item.type !== 'Location' && !(isPath(item) && item.item.is_dir)) {
|
||||
getQuickPreviewStore().itemIndex = itemIndex;
|
||||
getQuickPreviewStore().open = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.dirs.length > 0) {
|
||||
const [item] = items.dirs;
|
||||
if (item) {
|
||||
navigate({
|
||||
pathname: `../location/${item.location_id}`,
|
||||
search: createSearchParams({
|
||||
path: `${item.materialized_path}${item.name}/`
|
||||
}).toString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (items.locations.length > 0) {
|
||||
const [location] = items.locations;
|
||||
if (location) {
|
||||
navigate({
|
||||
pathname: `../location/${location.id}`,
|
||||
search: createSearchParams({
|
||||
path: `/`
|
||||
}).toString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (items.non_indexed.length > 0) {
|
||||
const [non_indexed] = items.non_indexed;
|
||||
if (non_indexed) {
|
||||
navigate({
|
||||
search: createSearchParams({ path: non_indexed.path }).toString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
explorer.selectedItems,
|
||||
explorer.settingsStore.openOnDoubleClick,
|
||||
library.uuid,
|
||||
navigate,
|
||||
openFilePaths,
|
||||
updateAccessTime
|
||||
]
|
||||
);
|
||||
|
||||
return { doubleClick };
|
||||
};
|
||||
|
||||
interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement> {
|
||||
data: ExplorerItem;
|
||||
}
|
||||
|
||||
export const ViewItem = memo(({ data, children, ...props }: ViewItemProps) => {
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
const { doubleClick } = useViewItemDoubleClick();
|
||||
|
||||
return (
|
||||
<ContextMenu.Root
|
||||
trigger={
|
||||
<div onDoubleClick={() => doubleClick(data)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
onOpenChange={explorerView.setIsContextMenuOpen}
|
||||
disabled={explorerView.contextMenu === undefined}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{explorerView.contextMenu}
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
});
|
|
@ -42,232 +42,101 @@ import { useExplorerViewContext, ViewContext, type ExplorerViewContext } from '.
|
|||
import GridView from './GridView';
|
||||
import ListView from './ListView';
|
||||
import MediaView from './MediaView';
|
||||
|
||||
interface ViewItemProps extends PropsWithChildren, HTMLAttributes<HTMLDivElement> {
|
||||
data: ExplorerItem;
|
||||
}
|
||||
|
||||
export const ViewItem = ({ data, children, ...props }: ViewItemProps) => {
|
||||
const explorer = useExplorerContext();
|
||||
const explorerView = useExplorerViewContext();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { library } = useLibraryContext();
|
||||
const { openFilePaths } = usePlatform();
|
||||
|
||||
const updateAccessTime = useLibraryMutation('files.updateAccessTime');
|
||||
const metaCtrlKey = useKeyMatcher('Meta').key;
|
||||
|
||||
useKeys([metaCtrlKey, 'ArrowUp'], async (e) => {
|
||||
e.stopPropagation();
|
||||
await onDoubleClick();
|
||||
});
|
||||
|
||||
const onDoubleClick = async () => {
|
||||
const selectedItems = [...explorer.selectedItems];
|
||||
|
||||
if (!isNonEmpty(selectedItems)) return;
|
||||
|
||||
let itemIndex = 0;
|
||||
const items = selectedItems.reduce(
|
||||
(items, item, i) => {
|
||||
const sameAsClicked = uniqueId(data) === uniqueId(item);
|
||||
|
||||
if (sameAsClicked) itemIndex = i;
|
||||
|
||||
switch (item.type) {
|
||||
case 'Location': {
|
||||
items.locations.splice(sameAsClicked ? 0 : -1, 0, item.item);
|
||||
break;
|
||||
}
|
||||
case 'NonIndexedPath': {
|
||||
items.non_indexed.splice(sameAsClicked ? 0 : -1, 0, item.item);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
for (const filePath of item.type === 'Path'
|
||||
? [item.item]
|
||||
: item.item.file_paths) {
|
||||
if (isPath(item) && item.item.is_dir) {
|
||||
items.dirs.splice(sameAsClicked ? 0 : -1, 0, filePath);
|
||||
} else {
|
||||
items.paths.splice(sameAsClicked ? 0 : -1, 0, filePath);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
{
|
||||
dirs: [],
|
||||
paths: [],
|
||||
locations: [],
|
||||
non_indexed: []
|
||||
} as {
|
||||
dirs: FilePath[];
|
||||
paths: FilePath[];
|
||||
locations: Location[];
|
||||
non_indexed: NonIndexedPathItem[];
|
||||
}
|
||||
);
|
||||
|
||||
if (items.paths.length > 0 && !explorerView.isRenaming) {
|
||||
if (explorer.settingsStore.openOnDoubleClick === 'openFile' && openFilePaths) {
|
||||
updateAccessTime
|
||||
.mutateAsync(items.paths.map(({ object_id }) => object_id!).filter(Boolean))
|
||||
.catch(console.error);
|
||||
|
||||
try {
|
||||
await openFilePaths(
|
||||
library.uuid,
|
||||
items.paths.map(({ id }) => id)
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error({ title: 'Failed to open file', body: `Error: ${error}.` });
|
||||
}
|
||||
} else if (explorer.settingsStore.openOnDoubleClick === 'quickPreview') {
|
||||
if (data.type !== 'Location' && !(isPath(data) && data.item.is_dir)) {
|
||||
getQuickPreviewStore().itemIndex = itemIndex;
|
||||
getQuickPreviewStore().open = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (items.dirs.length > 0) {
|
||||
const [item] = items.dirs;
|
||||
if (item) {
|
||||
navigate({
|
||||
pathname: `../location/${item.location_id}`,
|
||||
search: createSearchParams({
|
||||
path: `${item.materialized_path}${item.name}/`
|
||||
}).toString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (items.locations.length > 0) {
|
||||
const [location] = items.locations;
|
||||
if (location) {
|
||||
navigate({
|
||||
pathname: `../location/${location.id}`,
|
||||
search: createSearchParams({
|
||||
path: `/`
|
||||
}).toString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (items.non_indexed.length > 0) {
|
||||
const [non_indexed] = items.non_indexed;
|
||||
if (non_indexed) {
|
||||
navigate({
|
||||
search: createSearchParams({ path: non_indexed.path }).toString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu.Root
|
||||
trigger={
|
||||
<div onDoubleClick={onDoubleClick} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
onOpenChange={explorerView.setIsContextMenuOpen}
|
||||
disabled={explorerView.contextMenu === undefined}
|
||||
asChild={false}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{explorerView.contextMenu}
|
||||
</ContextMenu.Root>
|
||||
);
|
||||
};
|
||||
import { useViewItemDoubleClick } from './ViewItem';
|
||||
|
||||
export interface ExplorerViewProps
|
||||
extends Omit<
|
||||
ExplorerViewContext,
|
||||
'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen' | 'ref'
|
||||
'selectable' | 'isRenaming' | 'setIsRenaming' | 'setIsContextMenuOpen' | 'ref' | 'padding'
|
||||
> {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
emptyNotice?: JSX.Element;
|
||||
padding?: number | { x?: number; y?: number };
|
||||
}
|
||||
|
||||
export default memo(({ className, style, emptyNotice, ...contextProps }: ExplorerViewProps) => {
|
||||
const explorer = useExplorerContext();
|
||||
const quickPreviewStore = useQuickPreviewStore();
|
||||
export default memo(
|
||||
({ className, style, emptyNotice, padding, ...contextProps }: ExplorerViewProps) => {
|
||||
const explorer = useExplorerContext();
|
||||
const quickPreview = useQuickPreviewContext();
|
||||
const quickPreviewStore = useQuickPreviewStore();
|
||||
|
||||
const quickPreview = useQuickPreviewContext();
|
||||
const { doubleClick } = useViewItemDoubleClick();
|
||||
|
||||
const { layoutMode } = explorer.useSettingsSnapshot();
|
||||
const { layoutMode } = explorer.useSettingsSnapshot();
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const metaCtrlKey = useKeyMatcher('Meta').key;
|
||||
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useKeyDownHandlers({
|
||||
disabled: isRenaming || quickPreviewStore.open
|
||||
});
|
||||
const [isContextMenuOpen, setIsContextMenuOpen] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (explorer.isFetchingNextPage) {
|
||||
const timer = setTimeout(() => setShowLoading(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
} else setShowLoading(false);
|
||||
}, [explorer.isFetchingNextPage]);
|
||||
useKeyDownHandlers({
|
||||
disabled: isRenaming || quickPreviewStore.open
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className={clsx('h-full w-full', className)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2 || (e.button === 0 && e.shiftKey)) return;
|
||||
useEffect(() => {
|
||||
if (explorer.isFetchingNextPage) {
|
||||
const timer = setTimeout(() => setShowLoading(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
} else setShowLoading(false);
|
||||
}, [explorer.isFetchingNextPage]);
|
||||
|
||||
explorer.resetSelectedItems();
|
||||
}}
|
||||
>
|
||||
{explorer.items === null || (explorer.items && explorer.items.length > 0) ? (
|
||||
<ViewContext.Provider
|
||||
value={{
|
||||
...contextProps,
|
||||
selectable:
|
||||
explorer.selectable &&
|
||||
!isContextMenuOpen &&
|
||||
!isRenaming &&
|
||||
(!quickPreviewStore.open || explorer.selectedItems.size === 1),
|
||||
setIsContextMenuOpen,
|
||||
isRenaming,
|
||||
setIsRenaming,
|
||||
ref
|
||||
}}
|
||||
>
|
||||
{layoutMode === 'grid' && <GridView />}
|
||||
{layoutMode === 'list' && <ListView />}
|
||||
{layoutMode === 'media' && <MediaView />}
|
||||
{showLoading && (
|
||||
<Loader className="fixed bottom-10 left-0 w-[calc(100%+180px)]" />
|
||||
)}
|
||||
</ViewContext.Provider>
|
||||
) : (
|
||||
emptyNotice
|
||||
)}
|
||||
</div>
|
||||
useKeys([metaCtrlKey, 'ArrowUp'], (e) => {
|
||||
e.stopPropagation();
|
||||
doubleClick();
|
||||
});
|
||||
|
||||
{quickPreview.ref && createPortal(<QuickPreview />, quickPreview.ref)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
style={style}
|
||||
className={clsx('h-full w-full', className)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 2 || (e.button === 0 && e.shiftKey)) return;
|
||||
|
||||
explorer.resetSelectedItems();
|
||||
}}
|
||||
>
|
||||
{explorer.items === null || (explorer.items && explorer.items.length > 0) ? (
|
||||
<ViewContext.Provider
|
||||
value={{
|
||||
...contextProps,
|
||||
selectable:
|
||||
explorer.selectable &&
|
||||
!isContextMenuOpen &&
|
||||
!isRenaming &&
|
||||
(!quickPreviewStore.open || explorer.selectedItems.size === 1),
|
||||
setIsContextMenuOpen,
|
||||
isRenaming,
|
||||
setIsRenaming,
|
||||
ref,
|
||||
padding: {
|
||||
x: typeof padding === 'object' ? padding.x : padding,
|
||||
y: typeof padding === 'object' ? padding.y : padding
|
||||
}
|
||||
}}
|
||||
>
|
||||
{layoutMode === 'grid' && <GridView />}
|
||||
{layoutMode === 'list' && <ListView />}
|
||||
{layoutMode === 'media' && <MediaView />}
|
||||
{showLoading && (
|
||||
<Loader className="fixed bottom-10 left-0 w-[calc(100%+180px)]" />
|
||||
)}
|
||||
</ViewContext.Provider>
|
||||
) : (
|
||||
emptyNotice
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quickPreview.ref && createPortal(<QuickPreview />, quickPreview.ref)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const EmptyNotice = (props: { icon?: Icon | ReactNode; message?: ReactNode }) => {
|
||||
const { layoutMode } = useExplorerContext().useSettingsSnapshot();
|
||||
|
|
|
@ -7,9 +7,12 @@ export interface ExplorerViewContext {
|
|||
setIsContextMenuOpen?: (isOpen: boolean) => void;
|
||||
isRenaming: boolean;
|
||||
setIsRenaming: (isRenaming: boolean) => void;
|
||||
padding?: number | { x?: number; y?: number };
|
||||
padding?: { x?: number; y?: number };
|
||||
gap?: number | { x?: number; y?: number };
|
||||
selectable: boolean;
|
||||
listViewOptions?: {
|
||||
hideHeaderBorder?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const ViewContext = createContext<ExplorerViewContext | null>(null);
|
||||
|
|
|
@ -62,6 +62,7 @@ export default function Explorer(props: PropsWithChildren<Props>) {
|
|||
/>
|
||||
)
|
||||
}
|
||||
listViewOptions={{ hideHeaderBorder: true }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -170,6 +170,10 @@ function useSelectedItems(items: ExplorerItem[] | null) {
|
|||
updateHashes();
|
||||
},
|
||||
[selectedItemHashes.value, updateHashes]
|
||||
),
|
||||
isItemSelected: useCallback(
|
||||
(item: ExplorerItem) => selectedItems.has(item),
|
||||
[selectedItems]
|
||||
)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -46,7 +46,7 @@ export const Component = () => {
|
|||
|
||||
<div className="flex flex-1">
|
||||
<View
|
||||
top={68}
|
||||
top={114}
|
||||
className={settings.layoutMode === 'list' ? 'min-w-0' : undefined}
|
||||
contextMenu={
|
||||
<ContextMenu>
|
||||
|
|
|
@ -34,9 +34,8 @@
|
|||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tanstack/react-query": "^4.12.0",
|
||||
"@tanstack/react-query-devtools": "^4.22.0",
|
||||
"@tanstack/react-table": "^8.8.5",
|
||||
"@tanstack/react-table": "^8.10.0",
|
||||
"@tanstack/react-virtual": "3.0.0-beta.54",
|
||||
"@types/react-scroll-sync": "^0.8.4",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@vitejs/plugin-react": "^2.1.0",
|
||||
"autoprefixer": "^10.4.12",
|
||||
|
@ -61,7 +60,6 @@
|
|||
"react-qr-code": "^2.0.11",
|
||||
"react-router": "6.9.0",
|
||||
"react-router-dom": "6.9.0",
|
||||
"react-scroll-sync": "^0.11.0",
|
||||
"react-selecto": "^1.26.0",
|
||||
"react-sticky-el": "^2.1.0",
|
||||
"react-use-draggable-scroll": "^0.4.7",
|
||||
|
|
|
@ -7,3 +7,4 @@ export const generatePassword = (length: number) =>
|
|||
export type NonEmptyArray<T> = [T, ...T[]];
|
||||
|
||||
export const isNonEmpty = <T,>(input: T[]): input is NonEmptyArray<T> => input.length > 0;
|
||||
export const isNonEmptyObject = (input: object) => Object.keys(input).length > 0;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import type { ExplorerItem, FilePath, Object } from '../core';
|
||||
import type { ExplorerItem, FilePath, NonIndexedPathItem, Object } from '../core';
|
||||
import { byteSize } from '../lib';
|
||||
import { ObjectKind } from './objectKind';
|
||||
|
||||
|
@ -32,6 +32,7 @@ export function getExplorerItemData(data?: null | ExplorerItem) {
|
|||
|
||||
const itemData = {
|
||||
name: null as string | null,
|
||||
fullName: null as string | null,
|
||||
size: byteSize(0),
|
||||
kind,
|
||||
isDir: false,
|
||||
|
@ -52,6 +53,9 @@ export function getExplorerItemData(data?: null | ExplorerItem) {
|
|||
const location = getItemLocation(data);
|
||||
if (filePath) {
|
||||
itemData.name = filePath.name;
|
||||
itemData.fullName = `${filePath.name}${
|
||||
filePath.extension ? `.${filePath.extension}` : ''
|
||||
}}`;
|
||||
itemData.size = byteSize(filePath.size_in_bytes_bytes);
|
||||
itemData.isDir = filePath.is_dir ?? false;
|
||||
itemData.extension = filePath.extension;
|
||||
|
@ -65,6 +69,7 @@ export function getExplorerItemData(data?: null | ExplorerItem) {
|
|||
itemData.size = byteSize(location.total_capacity - location.available_capacity);
|
||||
|
||||
itemData.name = location.name;
|
||||
itemData.fullName = location.name;
|
||||
itemData.kind = ObjectKind[ObjectKind.Folder] ?? 'Unknown';
|
||||
itemData.isDir = true;
|
||||
itemData.locationId = location.id;
|
||||
|
|
|
@ -671,14 +671,11 @@ importers:
|
|||
specifier: ^4.22.0
|
||||
version: 4.22.0(@tanstack/react-query@4.29.1)(react-dom@18.2.0)(react@18.2.0)
|
||||
'@tanstack/react-table':
|
||||
specifier: ^8.8.5
|
||||
version: 8.8.5(react-dom@18.2.0)(react@18.2.0)
|
||||
specifier: ^8.10.0
|
||||
version: 8.10.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: 3.0.0-beta.54
|
||||
version: 3.0.0-beta.54(react@18.2.0)
|
||||
'@types/react-scroll-sync':
|
||||
specifier: ^0.8.4
|
||||
version: 0.8.4
|
||||
'@types/uuid':
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
|
@ -748,9 +745,6 @@ importers:
|
|||
react-router-dom:
|
||||
specifier: 6.9.0
|
||||
version: 6.9.0(react-dom@18.2.0)(react@18.2.0)
|
||||
react-scroll-sync:
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(react-dom@18.2.0)(react@18.2.0)
|
||||
react-selecto:
|
||||
specifier: ^1.26.0
|
||||
version: 1.26.0
|
||||
|
@ -10081,14 +10075,14 @@ packages:
|
|||
react-native: 0.72.4(@babel/core@7.22.11)(@babel/preset-env@7.22.10)(react@18.2.0)
|
||||
use-sync-external-store: 1.2.0(react@18.2.0)
|
||||
|
||||
/@tanstack/react-table@8.8.5(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-g/t21E/ICHvaCOJOhsDNr5QaB/6aDQEHFbx/YliwwU/CJThMqG+dS28vnToIBV/5MBgpeXoGRi2waDJVJlZrtg==}
|
||||
/@tanstack/react-table@8.10.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-FNhKE3525hryvuWw90xRbP16qNiq7OLJkDZopOKcwyktErLi1ibJzAN9DFwA/gR1br9SK4StXZh9JPvp9izrrQ==}
|
||||
engines: {node: '>=12'}
|
||||
peerDependencies:
|
||||
react: '>=16'
|
||||
react-dom: '>=16'
|
||||
dependencies:
|
||||
'@tanstack/table-core': 8.8.5
|
||||
'@tanstack/table-core': 8.10.0
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
@ -10102,8 +10096,8 @@ packages:
|
|||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@tanstack/table-core@8.8.5:
|
||||
resolution: {integrity: sha512-Xnwa1qxpgvSW7ozLiexmKp2PIYcLBiY/IizbdGriYCL6OOHuZ9baRhrrH51zjyz+61ly6K59rmt6AI/3RR+97Q==}
|
||||
/@tanstack/table-core@8.10.0:
|
||||
resolution: {integrity: sha512-e701yAJ18aGDP6mzVworlFAmQ+gi3Wtqx5mGZUe2BUv4W4D80dJxUfkHdtEGJ6GryAnlCCNTej7eaJiYmPhyYg==}
|
||||
engines: {node: '>=12'}
|
||||
dev: false
|
||||
|
||||
|
@ -10578,12 +10572,6 @@ packages:
|
|||
'@types/react': 18.0.38
|
||||
dev: true
|
||||
|
||||
/@types/react-scroll-sync@0.8.4:
|
||||
resolution: {integrity: sha512-88N2vgZdVqlwr5ayH/5GNAAjfdlzhted/qPTyXgT/DzQwsuIWkwFpZtoOhyGCRmxUC3w5wA+ZhkpbzagIXWNaQ==}
|
||||
dependencies:
|
||||
'@types/react': 18.0.38
|
||||
dev: false
|
||||
|
||||
/@types/react@18.0.38:
|
||||
resolution: {integrity: sha512-ExsidLLSzYj4cvaQjGnQCk4HFfVT9+EZ9XZsQ8Hsrcn8QNgXtpZ3m9vSIC2MWtx7jHictK6wYhQgGh6ic58oOw==}
|
||||
dependencies:
|
||||
|
@ -20417,17 +20405,6 @@ packages:
|
|||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/react-scroll-sync@0.11.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-COfA885/2NAQPxusIU6sukKiSFXgAd9+OWD7iBWA4NF5Y1Np8poBKLwgZ3y4iywo5/+ch0PLGz887myIbm+fPw==}
|
||||
peerDependencies:
|
||||
react: 16.x || 17.x || 18.x
|
||||
react-dom: 16.x || 17.x || 18.x
|
||||
dependencies:
|
||||
prop-types: 15.8.1
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-selecto@1.26.0:
|
||||
resolution: {integrity: sha512-aBTZEYA68uE+o8TytNjTb2GpIn4oKEv0U4LIow3cspJQlF/PdAnBwkq9UuiKVuFluu5kfLQ7Keu3S2Tihlmw0g==}
|
||||
dependencies:
|
||||
|
|
Loading…
Reference in a new issue