[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:
nikec 2023-09-24 14:16:38 +02:00 committed by GitHub
parent 052028a9c7
commit e3d69fe1b5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 1614 additions and 1523 deletions

View file

@ -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),

View file

@ -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

View 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>
);
};

View 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 };
};

View 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 };
};

View file

@ -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;

View 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>
);
});

View file

@ -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();

View file

@ -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);

View file

@ -62,6 +62,7 @@ export default function Explorer(props: PropsWithChildren<Props>) {
/>
)
}
listViewOptions={{ hideHeaderBorder: true }}
/>
</div>
</div>

View file

@ -170,6 +170,10 @@ function useSelectedItems(items: ExplorerItem[] | null) {
updateHashes();
},
[selectedItemHashes.value, updateHashes]
),
isItemSelected: useCallback(
(item: ExplorerItem) => selectedItems.has(item),
[selectedItems]
)
};
}

View file

@ -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>

View file

@ -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",

View file

@ -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;

View file

@ -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;

View file

@ -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: