diff --git a/src/app/components/WorksheetExplorer.test.tsx b/src/app/components/WorksheetExplorer.test.tsx
index 33680d5..0267bcd 100644
--- a/src/app/components/WorksheetExplorer.test.tsx
+++ b/src/app/components/WorksheetExplorer.test.tsx
@@ -420,4 +420,257 @@ describe('WorksheetExplorer', () => {
expect(await screen.findByDisplayValue('Second Worksheet')).toBeVisible()
})
})
+ describe('selecting several worksheets', () => {
+ // Explicit sort orders, so the rows sit in the order the tests name them:
+ // the list falls back to newest-first for worksheets without one.
+ const threeWorksheets: WorksheetDto[] = [
+ { ...testWorksheet, sortOrder: 0 },
+ { ...secondWorksheet, sortOrder: 1 },
+ { ...testWorksheet, id: 'ws-789', name: 'Third Worksheet', sortOrder: 2 }
+ ]
+
+ // What the sidebar acts on together, read off the rows rather than the
+ // store: the highlight is the only thing telling the user what a drag or a
+ // delete is about to take with it.
+ function selectedNames(): string[] {
+ return screen
+ .getAllByRole('button')
+ .filter((button) => button.dataset.selected === 'true')
+ .map((button) => button.textContent ?? '')
+ }
+
+ it('adds a row to the selection on a command-click, without opening it', () => {
+ const { store } = renderWithProviders(, {
+ databases: [],
+ openWorksheetId: 'ws-123',
+ worksheets: threeWorksheets
+ })
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Second Worksheet' }),
+ {
+ metaKey: true
+ }
+ )
+
+ // The open worksheet joins the selection it started: a command-click adds
+ // a row, so it cannot be the thing that drops the row you were on.
+ expect({
+ openWorksheetId: store.getState().tabs.activeWorksheetId,
+ selected: selectedNames()
+ }).toEqual({
+ openWorksheetId: 'ws-123',
+ selected: ['Test Worksheet', 'Second Worksheet']
+ })
+ })
+
+ // `mod` is ⌘ on macOS and Ctrl everywhere else, and the click handler is
+ // the one place in the sidebar that has to know both.
+ it('adds a row on a control-click too', () => {
+ renderWithProviders(, {
+ databases: [],
+ openWorksheetId: 'ws-123',
+ worksheets: threeWorksheets
+ })
+
+ fireEvent.click(
+ screen.getByRole('button', { name: 'Second Worksheet' }),
+ {
+ ctrlKey: true
+ }
+ )
+
+ expect(selectedNames()).toEqual(['Test Worksheet', 'Second Worksheet'])
+ })
+
+ it('takes a selected row back out on a second command-click', () => {
+ renderWithProviders(, {
+ databases: [],
+ openWorksheetId: 'ws-123',
+ worksheets: threeWorksheets
+ })
+
+ const row = screen.getByRole('button', { name: 'Second Worksheet' })
+
+ fireEvent.click(row, { metaKey: true })
+ fireEvent.click(row, { metaKey: true })
+
+ expect(selectedNames()).toEqual(['Test Worksheet'])
+ })
+
+ it('selects the range from the open row on a shift-click', () => {
+ const { store } = renderWithProviders(, {
+ databases: [],
+ openWorksheetId: 'ws-123',
+ worksheets: threeWorksheets
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Third Worksheet' }), {
+ shiftKey: true
+ })
+
+ expect({
+ openWorksheetId: store.getState().tabs.activeWorksheetId,
+ selected: selectedNames()
+ }).toEqual({
+ openWorksheetId: 'ws-123',
+ selected: ['Test Worksheet', 'Second Worksheet', 'Third Worksheet']
+ })
+ })
+
+ it('drops the selection and opens the worksheet on a plain click', () => {
+ const { store } = renderWithProviders(, {
+ databases: [],
+ openWorksheetId: 'ws-123',
+ worksheets: threeWorksheets
+ })
+
+ fireEvent.click(screen.getByRole('button', { name: 'Third Worksheet' }), {
+ shiftKey: true
+ })
+ fireEvent.click(screen.getByRole('button', { name: 'Second Worksheet' }))
+
+ expect({
+ openWorksheetId: store.getState().tabs.activeWorksheetId,
+ selected: selectedNames()
+ }).toEqual({
+ openWorksheetId: 'ws-456',
+ selected: ['Second Worksheet']
+ })
+ })
+
+ describe('deleting the selection', () => {
+ const selectingTwo = {
+ worksheetSelection: { anchorId: 'ws-123', ids: ['ws-123', 'ws-456'] }
+ }
+
+ it('deletes every selected worksheet after one confirmation', async () => {
+ const user = userEvent.setup()
+
+ vi.mocked(apiClient.deleteWorksheet).mockResolvedValue(undefined)
+
+ renderWithProviders(, {
+ databases: [],
+ editor: selectingTwo,
+ openWorksheetId: 'ws-123',
+ worksheets: threeWorksheets
+ })
+
+ fireEvent.contextMenu(screen.getByText('Second Worksheet'))
+
+ await user.click(
+ await screen.findByRole('menuitem', { name: 'Delete 2 worksheets' })
+ )
+
+ expect(await screen.findByText('Delete 2 worksheets?')).toBeVisible()
+ expect(apiClient.deleteWorksheet).not.toHaveBeenCalled()
+
+ await user.click(screen.getByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => {
+ expect(vi.mocked(apiClient.deleteWorksheet).mock.calls).toEqual([
+ ['ws-123'],
+ ['ws-456']
+ ])
+ })
+
+ expect(await screen.findByText('Deleted 2 worksheets')).toBeVisible()
+ })
+
+ // Renaming three worksheets at once means nothing, so the menu does not
+ // offer it rather than quietly renaming the one that was right-clicked.
+ it('offers no rename while several rows are selected', async () => {
+ renderWithProviders(, {
+ databases: [],
+ editor: selectingTwo,
+ openWorksheetId: 'ws-123',
+ worksheets: threeWorksheets
+ })
+
+ fireEvent.contextMenu(screen.getByText('Second Worksheet'))
+
+ await screen.findByRole('menuitem', { name: 'Delete 2 worksheets' })
+
+ expect(
+ screen.queryByRole('menuitem', { name: 'Rename' })
+ ).not.toBeInTheDocument()
+ })
+
+ // Right-clicking outside the selection is how a file manager behaves:
+ // the row you pointed at becomes the selection, and the menu acts on it.
+ it('acts on the row alone when it is not part of the selection', async () => {
+ const user = userEvent.setup()
+
+ vi.mocked(apiClient.deleteWorksheet).mockResolvedValue(undefined)
+
+ renderWithProviders(, {
+ databases: [],
+ editor: selectingTwo,
+ openWorksheetId: 'ws-123',
+ worksheets: threeWorksheets
+ })
+
+ fireEvent.contextMenu(screen.getByText('Third Worksheet'))
+
+ await user.click(
+ await screen.findByRole('menuitem', { name: 'Delete' })
+ )
+ await user.click(await screen.findByRole('button', { name: 'Delete' }))
+
+ await waitFor(() => {
+ expect(vi.mocked(apiClient.deleteWorksheet).mock.calls).toEqual([
+ ['ws-789']
+ ])
+ })
+ })
+
+ // Same rule as the single row: the app is built around always having a
+ // worksheet open, and the list endpoint would recreate a default one.
+ it('disables the delete that would empty the list', async () => {
+ renderWithProviders(, {
+ databases: [],
+ editor: selectingTwo,
+ openWorksheetId: 'ws-123',
+ worksheets: [testWorksheet, secondWorksheet]
+ })
+
+ fireEvent.contextMenu(screen.getByText('Second Worksheet'))
+
+ expect(
+ await screen.findByRole('menuitem', { name: 'Delete 2 worksheets' })
+ ).toHaveAttribute('aria-disabled', 'true')
+ })
+
+ it('says how many of them failed when only some are deleted', async () => {
+ const user = userEvent.setup()
+
+ vi.mocked(apiClient.deleteWorksheet).mockImplementation(
+ async (worksheetId: string) => {
+ if (worksheetId === 'ws-456') {
+ throw new Error('Database is locked')
+ }
+ }
+ )
+
+ renderWithProviders(, {
+ databases: [],
+ editor: selectingTwo,
+ openWorksheetId: 'ws-123',
+ worksheets: threeWorksheets
+ })
+
+ fireEvent.contextMenu(screen.getByText('Second Worksheet'))
+
+ await user.click(
+ await screen.findByRole('menuitem', { name: 'Delete 2 worksheets' })
+ )
+ await user.click(screen.getByRole('button', { name: 'Delete' }))
+
+ expect(
+ await screen.findByText('Failed to delete 1 of 2 worksheets')
+ ).toBeVisible()
+ expect(await screen.findByText('Database is locked')).toBeVisible()
+ })
+ })
+ })
})
diff --git a/src/app/components/WorksheetExplorer.tsx b/src/app/components/WorksheetExplorer.tsx
index 48bec36..b99175d 100644
--- a/src/app/components/WorksheetExplorer.tsx
+++ b/src/app/components/WorksheetExplorer.tsx
@@ -17,6 +17,7 @@ import {
useOpenWorksheet
} from '../hooks/use-worksheet-commands'
import { useWorksheetRename } from '../hooks/use-worksheet-rename'
+import { useWorksheetSelection } from '../hooks/use-worksheet-selection'
import { cn } from '../lib/utils'
import { useAppDispatch, useAppSelector } from '../store'
import { worksheetSearchQueryUpdated } from '../store/editor-slice'
@@ -32,34 +33,76 @@ import { SearchInput } from './SearchInput'
import { WorksheetNameInput } from './WorksheetNameInput'
import { WorksheetDto } from '@/glue/worksheets'
+// One worksheet is named; several are counted. The count is what makes a
+// multi-row delete safe to confirm — "Delete 3 worksheets?" is the only place
+// the user learns the menu meant more than the row they right-clicked.
+function describeWorksheets(worksheets: WorksheetDto[]): string {
+ return worksheets.length === 1
+ ? `"${worksheets[0].name}"`
+ : `${worksheets.length} worksheets`
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : 'Unknown error'
+}
+
// Deleting takes the editor content with it, so it asks first via an action
// toast — ignoring it is a safe no. Mirrors the database explorer.
-function useConfirmedWorksheetDeletion(): (worksheet: WorksheetDto) => void {
+function useConfirmedWorksheetDeletion(): (worksheets: WorksheetDto[]) => void {
const deleteWorksheet = useDeleteWorksheet()
return useCallback(
- (worksheet: WorksheetDto) => {
- toast(`Delete "${worksheet.name}"?`, {
+ (worksheets: WorksheetDto[]) => {
+ if (worksheets.length === 0) {
+ return
+ }
+
+ const described = describeWorksheets(worksheets)
+
+ toast(`Delete ${described}?`, {
action: {
label: 'Delete',
onClick: () => {
- deleteWorksheet.mutate(worksheet.id, {
- onError: (error) => {
- const message =
- error instanceof Error ? error.message : 'Unknown error'
-
- toast.error('Failed to delete worksheet', {
- description: message
- })
- },
- onSuccess: () => {
- toast.success(`Deleted "${worksheet.name}"`)
+ // `allSettled`, so one row that will not go does not strand the
+ // others: every delete is attempted and the toast afterwards says
+ // how many made it. The selection needs no clearing — it is pruned
+ // to the rows that still exist on the next render.
+ const deletions = Promise.allSettled(
+ worksheets.map((worksheet) =>
+ deleteWorksheet.mutateAsync(worksheet.id)
+ )
+ )
+
+ void deletions.then((results) => {
+ const failures = results.filter(
+ (result) => result.status === 'rejected'
+ )
+
+ if (failures.length === 0) {
+ toast.success(`Deleted ${described}`)
+
+ return
}
+
+ const description = errorMessage(failures[0].reason)
+
+ if (worksheets.length === 1) {
+ toast.error('Failed to delete worksheet', { description })
+
+ return
+ }
+
+ toast.error(
+ `Failed to delete ${failures.length} of ${worksheets.length} worksheets`,
+ { description }
+ )
})
}
},
description:
- 'This worksheet and its editor content will be removed. Query history is kept.'
+ worksheets.length === 1
+ ? 'This worksheet and its editor content will be removed. Query history is kept.'
+ : 'These worksheets and their editor content will be removed. Query history is kept.'
})
},
[deleteWorksheet]
@@ -106,15 +149,65 @@ export function WorksheetExplorer(): ReactElement {
const filteredWorksheetIds = filteredWorksheets.map(
(worksheet) => worksheet.id
)
+ const worksheetIds = worksheets.data.map((worksheet) => worksheet.id)
+
+ const selection = useWorksheetSelection(worksheetIds)
+
// The reorder runs over the whole list; the filtered rows on screen are a
// subsequence of it, and asking for the indicator by id is what lets those
// two lists differ without any index having to line up.
- const { dndContextProps, dropIndicatorFor } = useListReorder({
+ const { dndContextProps, dropIndicatorFor, isMoving } = useListReorder({
axis: 'vertical',
- ids: worksheets.data.map((worksheet) => worksheet.id),
- onReorder: reorderWorksheets.mutate
+ ids: worksheetIds,
+ onReorder: reorderWorksheets.mutate,
+ selectedIds: selection.ids
})
+ // Command- and shift-click pick rows out; a plain click is still what opens
+ // one. Modified clicks deliberately leave the open worksheet where it is —
+ // picking rows out to move them should not swap the editor under the user.
+ const handleRowClick = (
+ event: React.MouseEvent,
+ worksheetId: string
+ ): void => {
+ // ⌘ on macOS, Ctrl everywhere else.
+ if (event.metaKey || event.ctrlKey) {
+ selection.toggle(worksheetId)
+
+ return
+ }
+
+ if (event.shiftKey) {
+ selection.extend(worksheetId)
+
+ return
+ }
+
+ selection.replace(worksheetId)
+ handleSelectWorksheet(worksheetId)
+ }
+
+ // Right-clicking outside the selection makes that row the selection, the way
+ // a file manager does, so the menu can never act on rows the user is not
+ // pointing at.
+ const handleRowContextMenu = (worksheetId: string): void => {
+ if (!selection.isSelected(worksheetId)) {
+ selection.replace(worksheetId)
+ }
+ }
+
+ const selectedWorksheets = worksheets.data.filter((worksheet) =>
+ selection.isSelected(worksheet.id)
+ )
+
+ // A row inside a multi-row selection opens a menu about the whole selection;
+ // anything else is about itself, including the single row a selection has
+ // shrunk to.
+ const deleteTargetsFor = (worksheet: WorksheetDto): WorksheetDto[] =>
+ selectedWorksheets.length > 1 && selection.isSelected(worksheet.id)
+ ? selectedWorksheets
+ : [worksheet]
+
// Worksheets show which database they run against, so the names are looked
// up once per render instead of per row.
const databaseNames = new Map(
@@ -173,6 +266,7 @@ export function WorksheetExplorer(): ReactElement {
) : (
1}
databaseName={
worksheet.databaseId
? databaseNames.get(worksheet.databaseId)
: undefined
}
+ deleteTargets={deleteTargetsFor(worksheet)}
isOpen={worksheet.id === openWorksheetId}
+ isSelected={selection.isSelected(worksheet.id)}
+ remainingWorksheetCount={worksheets.data.length}
worksheet={worksheet}
+ onContextMenu={handleRowContextMenu}
onDelete={handleDeleteWorksheet}
onDoubleClick={startEditing}
- onSelect={handleSelectWorksheet}
+ onSelect={handleRowClick}
/>
)}
@@ -215,6 +312,13 @@ export function WorksheetExplorer(): ReactElement {
interface WorksheetRowProps {
children: ReactNode
dropIndicator: DropIndicator
+ /**
+ * Whether this row is travelling with the drag. Not `useSortable`'s
+ * `isDragging`, which only knows about the row under the cursor: a selection
+ * dragged as a group carries rows the cursor never touched, and dimming all
+ * of them is the only thing that says what is being carried.
+ */
+ isMoving: boolean
isSortingDisabled: boolean
worksheetId: string
}
@@ -227,16 +331,19 @@ interface WorksheetRowProps {
function WorksheetRow({
children,
dropIndicator,
+ isMoving,
isSortingDisabled,
worksheetId
}: WorksheetRowProps): ReactElement {
- const { isDragging, listeners, setNodeRef, transform, transition } =
- useSortable({ disabled: isSortingDisabled, id: worksheetId })
+ const { listeners, setNodeRef, transform, transition } = useSortable({
+ disabled: isSortingDisabled,
+ id: worksheetId
+ })
return (
@@ -253,36 +360,52 @@ function WorksheetRow({
}
interface WorksheetListItemProps {
- // False for the last remaining worksheet: the app is built around always
- // having one open, and the list endpoint would just recreate a default.
- canDelete: boolean
databaseName?: string
+ /** What this row's menu acts on: itself, or the selection it belongs to. */
+ deleteTargets: WorksheetDto[]
isOpen: boolean
+ isSelected: boolean
+ /** How many worksheets there are, which is what caps a delete. */
+ remainingWorksheetCount: number
worksheet: WorksheetDto
- onDelete: (worksheet: WorksheetDto) => void
+ onContextMenu: (worksheetId: string) => void
+ onDelete: (worksheets: WorksheetDto[]) => void
onDoubleClick: (worksheet: WorksheetDto) => void
- onSelect: (worksheetId: string) => void
+ onSelect: (event: React.MouseEvent, worksheetId: string) => void
}
function WorksheetListItem({
- canDelete,
databaseName,
+ deleteTargets,
isOpen,
+ isSelected,
+ remainingWorksheetCount,
worksheet,
+ onContextMenu,
onDelete,
onDoubleClick,
onSelect
}: WorksheetListItemProps): ReactElement {
+ // The app is built around always having a worksheet open, and the list
+ // endpoint would just recreate a default one.
+ const canDelete = remainingWorksheetCount > deleteTargets.length
+
+ // Renaming several rows at once means nothing, so the menu drops the item
+ // rather than quietly renaming whichever one was right-clicked.
+ const isRenamable = deleteTargets.length === 1
+
return (
diff --git a/src/app/hooks/use-list-reorder.test.ts b/src/app/hooks/use-list-reorder.test.ts
index d4ebc9a..b199321 100644
--- a/src/app/hooks/use-list-reorder.test.ts
+++ b/src/app/hooks/use-list-reorder.test.ts
@@ -33,10 +33,15 @@ function dragEnd(activeId: string, overId: string | null): DragEndEvent {
} as DragEndEvent
}
-function renderListReorder(onReorder = vi.fn()) {
+function renderListReorder(onReorder = vi.fn(), selectedIds?: string[]) {
const rendered = renderHook(
(props: { ids: string[] }) =>
- useListReorder({ axis: 'horizontal', ids: props.ids, onReorder }),
+ useListReorder({
+ axis: 'horizontal',
+ ids: props.ids,
+ onReorder,
+ selectedIds
+ }),
{ initialProps: { ids } }
)
@@ -314,6 +319,130 @@ describe('useListReorder', () => {
})
})
+ // Command- and shift-click in the worksheet list hand a whole group to the
+ // drag: grabbing any row in it moves all of them to the drop point.
+ describe('with several rows selected', () => {
+ it('moves the whole selection when the dragged row belongs to it', () => {
+ const { onReorder, result } = renderListReorder(vi.fn(), ['a', 'b'])
+
+ act(() => {
+ result.current.dndContextProps.onDragStart(dragStart('a'))
+ result.current.dndContextProps.onDragEnd(dragEnd('a', 'd'))
+ })
+
+ expect(onReorder.mock.calls).toEqual([[['c', 'd', 'a', 'b']]])
+ })
+
+ // Grabbing a row outside the selection is a plain single-row drag, the way
+ // it is in a file manager — the selection is not what is under the cursor.
+ it('moves only the dragged row when it is not part of the selection', () => {
+ const { onReorder, result } = renderListReorder(vi.fn(), ['b', 'c'])
+
+ act(() => {
+ result.current.dndContextProps.onDragStart(dragStart('a'))
+ result.current.dndContextProps.onDragEnd(dragEnd('a', 'c'))
+ })
+
+ expect(onReorder.mock.calls).toEqual([[['b', 'c', 'a', 'd']]])
+ })
+
+ // The rows travelling with the drag have no landing spot of their own, so
+ // a line on one of them would point at a drop that cannot happen.
+ it('shows no indicator on the rows travelling with the drag', () => {
+ const { result } = renderListReorder(vi.fn(), ['a', 'b'])
+
+ act(() => {
+ result.current.dndContextProps.onDragStart(dragStart('a'))
+ result.current.dndContextProps.onDragOver(dragOver('b'))
+ })
+
+ expect(indicators(result.current.dropIndicatorFor)).toEqual({
+ a: null,
+ b: null,
+ c: null,
+ d: null
+ })
+ })
+
+ it('still marks a row outside the selection as the landing spot', () => {
+ const { result } = renderListReorder(vi.fn(), ['a', 'b'])
+
+ act(() => {
+ result.current.dndContextProps.onDragStart(dragStart('a'))
+ result.current.dndContextProps.onDragOver(dragOver('c'))
+ })
+
+ expect(indicators(result.current.dropIndicatorFor)).toEqual({
+ a: null,
+ b: null,
+ c: 'after',
+ d: null
+ })
+ })
+
+ it('reorders nothing when the drop lands inside the selection', () => {
+ const { onReorder, result } = renderListReorder(vi.fn(), ['a', 'b'])
+
+ act(() => {
+ result.current.dndContextProps.onDragStart(dragStart('a'))
+ result.current.dndContextProps.onDragEnd(dragEnd('a', 'b'))
+ })
+
+ expect(onReorder.mock.calls).toEqual([])
+ })
+ })
+
+ // Nothing renders a drag preview here, so dimming the rows that are
+ // travelling is the only thing that says what a group drag is carrying.
+ describe('isMoving', () => {
+ it('reports nothing as moving before a drag starts', () => {
+ const { result } = renderListReorder()
+
+ expect(ids.filter(result.current.isMoving)).toEqual([])
+ })
+
+ it('reports the dragged row as moving', () => {
+ const { result } = renderListReorder()
+
+ act(() => {
+ result.current.dndContextProps.onDragStart(dragStart('b'))
+ })
+
+ expect(ids.filter(result.current.isMoving)).toEqual(['b'])
+ })
+
+ it('reports the whole selection when the drag started on one of its rows', () => {
+ const { result } = renderListReorder(vi.fn(), ['a', 'c'])
+
+ act(() => {
+ result.current.dndContextProps.onDragStart(dragStart('a'))
+ })
+
+ expect(ids.filter(result.current.isMoving)).toEqual(['a', 'c'])
+ })
+
+ it('reports only the dragged row when it is not part of the selection', () => {
+ const { result } = renderListReorder(vi.fn(), ['b', 'c'])
+
+ act(() => {
+ result.current.dndContextProps.onDragStart(dragStart('a'))
+ })
+
+ expect(ids.filter(result.current.isMoving)).toEqual(['a'])
+ })
+
+ it('reports nothing as moving once the drag is over', () => {
+ const { result } = renderListReorder(vi.fn(), ['a', 'c'])
+
+ act(() => {
+ result.current.dndContextProps.onDragStart(dragStart('a'))
+ result.current.dndContextProps.onDragEnd(dragEnd('a', 'd'))
+ })
+
+ expect(ids.filter(result.current.isMoving)).toEqual([])
+ })
+ })
+
// The old pair of `useState`s kept `overId` across drags, so a second drag
// began with a line already drawn where the first one ended.
it('starts a second drag with nothing hovered', () => {
diff --git a/src/app/hooks/use-list-reorder.ts b/src/app/hooks/use-list-reorder.ts
index 05c5b15..5cda9da 100644
--- a/src/app/hooks/use-list-reorder.ts
+++ b/src/app/hooks/use-list-reorder.ts
@@ -13,9 +13,11 @@ import {
restrictToHorizontalAxis,
restrictToVerticalAxis
} from '@dnd-kit/modifiers'
-import { arrayMove, type SortingStrategy } from '@dnd-kit/sortable'
+import { type SortingStrategy } from '@dnd-kit/sortable'
import { useCallback, useState } from 'react'
+import { moveIds } from '../list-move'
+
// Named along the list rather than along a screen axis, so the same values
// describe a sidebar row and a tab.
export type DropIndicator = 'after' | 'before' | null
@@ -53,12 +55,33 @@ interface ListReorderDndContextProps {
interface ListReorder {
dndContextProps: ListReorderDndContextProps
dropIndicatorFor: (id: string) => DropIndicator
+ /**
+ * Whether the row is travelling with the drag in flight. `useSortable` knows
+ * this for the row under the cursor only, and a group drag carries rows the
+ * cursor never touched.
+ */
+ isMoving: (id: string) => boolean
}
interface UseListReorderOptions {
axis: 'horizontal' | 'vertical'
ids: string[]
onReorder: (orderedIds: string[]) => void
+ /**
+ * Rows the user has picked out, if the list offers that at all. Grabbing one
+ * of them drags the whole group; grabbing anything else is an ordinary
+ * single-row drag and leaves the selection where it is.
+ */
+ selectedIds?: string[]
+}
+
+// The rows this drag is carrying. A selection the dragged row is not part of
+// belongs to whatever else the list does with it, not to the drag.
+function movingIdsFor(
+ activeId: string,
+ selectedIds: string[] | undefined
+): string[] {
+ return selectedIds?.includes(activeId) ? selectedIds : [activeId]
}
/**
@@ -76,7 +99,7 @@ interface UseListReorderOptions {
* only have to be some subsequence of `ids` — nothing has to line up by index.
*/
export function useListReorder(options: UseListReorderOptions): ListReorder {
- const { axis, ids, onReorder } = options
+ const { axis, ids, onReorder, selectedIds } = options
const [drag, setDrag] = useState(null)
@@ -98,19 +121,24 @@ export function useListReorder(options: UseListReorderOptions): ListReorder {
return
}
- const activeIndex = ids.indexOf(String(active.id))
- const overIndex = ids.indexOf(String(over.id))
-
- // Same reason as the indicator below: `arrayMove` reads -1 as a position
- // and scrambles the order rather than refusing, so a row that has left
- // the list mid-drag has to be caught here.
- if (activeIndex === -1 || overIndex === -1) {
+ const activeId = String(active.id)
+ const nextIds = moveIds(
+ ids,
+ movingIdsFor(activeId, selectedIds),
+ activeId,
+ String(over.id)
+ )
+
+ // `moveIds` hands back the list it was given, by reference, for every
+ // drop that means nothing: a row that left the list mid-drag, or a drop
+ // onto a row that is itself travelling. Neither is worth a write.
+ if (nextIds === ids) {
return
}
- onReorder(arrayMove(ids, activeIndex, overIndex))
+ onReorder(nextIds)
},
- [ids, onReorder]
+ [ids, onReorder, selectedIds]
)
const handleDragOver = useCallback((event: DragOverEvent) => {
@@ -130,10 +158,15 @@ export function useListReorder(options: UseListReorderOptions): ListReorder {
// boundary. `ids` is a fresh array on most renders anyway.
//
// Dragging forwards lands after the hovered row, backwards before it,
- // matching how `arrayMove` resolves the drop. Dropping a row on itself
- // changes nothing, so it gets no line.
+ // matching how `moveIds` resolves the drop. A row that is travelling with
+ // the drag gets no line: dropping the group on one of its own rows changes
+ // nothing, so there is nothing to point at.
const dropIndicatorFor = (id: string): DropIndicator => {
- if (drag === null || drag.overId !== id || drag.activeId === id) {
+ if (drag === null || drag.overId !== id) {
+ return null
+ }
+
+ if (movingIdsFor(drag.activeId, selectedIds).includes(id)) {
return null
}
@@ -151,6 +184,9 @@ export function useListReorder(options: UseListReorderOptions): ListReorder {
return activeIndex < overIndex ? 'after' : 'before'
}
+ const isMoving = (id: string): boolean =>
+ drag !== null && movingIdsFor(drag.activeId, selectedIds).includes(id)
+
return {
dndContextProps: {
collisionDetection: closestCenter,
@@ -161,6 +197,7 @@ export function useListReorder(options: UseListReorderOptions): ListReorder {
onDragStart: handleDragStart,
sensors
},
- dropIndicatorFor
+ dropIndicatorFor,
+ isMoving
}
}
diff --git a/src/app/hooks/use-worksheet-selection.ts b/src/app/hooks/use-worksheet-selection.ts
new file mode 100644
index 0000000..6a859d4
--- /dev/null
+++ b/src/app/hooks/use-worksheet-selection.ts
@@ -0,0 +1,79 @@
+import { useAppDispatch, useAppSelector } from '../store'
+import {
+ extendSelection,
+ pruneSelection,
+ replaceSelection,
+ toggleSelection
+} from '../list-selection'
+import { worksheetSelectionChanged } from '../store/editor-slice'
+import { selectActiveWorksheetId } from '../store/tabs-slice'
+
+interface WorksheetSelection {
+ /** The selected rows, in list order. Never empty while a worksheet is open. */
+ ids: string[]
+ /** Shift-click: the range from the anchor to this row. */
+ extend: (worksheetId: string) => void
+ isSelected: (worksheetId: string) => boolean
+ /** A plain click: this row alone. */
+ replace: (worksheetId: string) => void
+ /** Command- or control-click: this row in or out of the selection. */
+ toggle: (worksheetId: string) => void
+}
+
+/**
+ * The worksheets the sidebar acts on together — what a drag carries and what a
+ * delete takes with it.
+ *
+ * Two things are resolved here rather than in the store, because both depend on
+ * the list rather than on the selection itself:
+ *
+ * - rows that have gone are dropped, so a worksheet deleted in this window or
+ * another one cannot leave an id behind for a later drag to move;
+ * - with nothing picked out, the open worksheet stands in for the selection.
+ * It is the row the user is on, so a command-click has to add to it rather
+ * than replace it — otherwise the first modified click silently drops the
+ * row you were working in.
+ *
+ * `orderedIds` is the whole worksheet list, not the filtered rows: a range is
+ * measured over the order the rows really sit in.
+ */
+export function useWorksheetSelection(
+ orderedIds: string[]
+): WorksheetSelection {
+ const dispatch = useAppDispatch()
+ const openWorksheetId = useAppSelector(selectActiveWorksheetId)
+ const storedSelection = useAppSelector(
+ (state) => state.editor.worksheetSelection
+ )
+
+ const pruned = pruneSelection(storedSelection, orderedIds)
+ const selection =
+ pruned ??
+ (openWorksheetId && orderedIds.includes(openWorksheetId)
+ ? replaceSelection(openWorksheetId)
+ : null)
+
+ const selected = new Set(selection?.ids ?? [])
+
+ return {
+ extend: (worksheetId) => {
+ dispatch(
+ worksheetSelectionChanged(
+ extendSelection(selection, worksheetId, orderedIds)
+ )
+ )
+ },
+ // Read off the list rather than the selection, so the order a drag and a
+ // delete see is the order on screen and not the order rows were clicked.
+ ids: orderedIds.filter((id) => selected.has(id)),
+ isSelected: (worksheetId) => selected.has(worksheetId),
+ replace: (worksheetId) => {
+ dispatch(worksheetSelectionChanged(replaceSelection(worksheetId)))
+ },
+ toggle: (worksheetId) => {
+ dispatch(
+ worksheetSelectionChanged(toggleSelection(selection, worksheetId))
+ )
+ }
+ }
+}
diff --git a/src/app/list-move.test.ts b/src/app/list-move.test.ts
new file mode 100644
index 0000000..0402f3b
--- /dev/null
+++ b/src/app/list-move.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest'
+
+import { moveIds } from './list-move'
+
+const ids = ['a', 'b', 'c', 'd', 'e']
+
+describe('moveIds', () => {
+ it('lands a row dragged forwards after the row it was dropped on', () => {
+ expect(moveIds(ids, ['a'], 'a', 'c')).toEqual(['b', 'c', 'a', 'd', 'e'])
+ })
+
+ it('lands a row dragged backwards before the row it was dropped on', () => {
+ expect(moveIds(ids, ['d'], 'd', 'b')).toEqual(['a', 'd', 'b', 'c', 'e'])
+ })
+
+ it('moves a whole block forwards, keeping the block in its own order', () => {
+ expect(moveIds(ids, ['a', 'b'], 'a', 'd')).toEqual([
+ 'c',
+ 'd',
+ 'a',
+ 'b',
+ 'e'
+ ])
+ })
+
+ it('moves a whole block backwards', () => {
+ expect(moveIds(ids, ['c', 'd'], 'd', 'b')).toEqual([
+ 'a',
+ 'c',
+ 'd',
+ 'b',
+ 'e'
+ ])
+ })
+
+ it('gathers a non-contiguous selection at the drop point', () => {
+ expect(moveIds(ids, ['a', 'c'], 'a', 'd')).toEqual([
+ 'b',
+ 'd',
+ 'a',
+ 'c',
+ 'e'
+ ])
+ })
+
+ it('takes the block order from the list, not from the ids it is handed', () => {
+ expect(moveIds(ids, ['c', 'a'], 'a', 'd')).toEqual([
+ 'b',
+ 'd',
+ 'a',
+ 'c',
+ 'e'
+ ])
+ })
+
+ it('changes nothing when the row is dropped on itself', () => {
+ expect(moveIds(ids, ['a'], 'a', 'a')).toEqual(ids)
+ })
+
+ it('changes nothing when the drop target is inside the moving block', () => {
+ expect(moveIds(ids, ['a', 'b', 'c'], 'a', 'b')).toEqual(ids)
+ })
+
+ it('changes nothing when the dragged row has left the list', () => {
+ expect(moveIds(ids, ['gone'], 'gone', 'c')).toEqual(ids)
+ })
+
+ it('changes nothing when the drop target has left the list', () => {
+ expect(moveIds(ids, ['a'], 'a', 'gone')).toEqual(ids)
+ })
+
+ it('ignores moving ids that are no longer in the list', () => {
+ expect(moveIds(ids, ['a', 'gone'], 'a', 'c')).toEqual([
+ 'b',
+ 'c',
+ 'a',
+ 'd',
+ 'e'
+ ])
+ })
+})
diff --git a/src/app/list-move.ts b/src/app/list-move.ts
new file mode 100644
index 0000000..21a2a92
--- /dev/null
+++ b/src/app/list-move.ts
@@ -0,0 +1,45 @@
+/**
+ * Moves `movingIds` to where they were dropped and returns the whole new
+ * order. One id is the ordinary single-row drag; several is a multi-selection
+ * dragged as a block.
+ *
+ * The block lands after `overId` when it was dragged forwards and before it
+ * when it was dragged backwards, which is the rule the drop indicator draws —
+ * `activeId` is the row under the cursor's grip, so it is what decides the
+ * direction even when the rest of the block sits on the other side of it.
+ *
+ * Anything inconsistent answers with the order it was given rather than a
+ * guess: a row deleted mid-drag, a drop onto a row that is itself moving, an
+ * id that is no longer in the list. Returning the list unchanged is a drag
+ * that did nothing, which is what the user sees anyway when they let go
+ * somewhere that means nothing.
+ */
+export function moveIds(
+ orderedIds: string[],
+ movingIds: string[],
+ activeId: string,
+ overId: string
+): string[] {
+ const moving = new Set(movingIds)
+
+ if (moving.has(overId)) {
+ return orderedIds
+ }
+
+ const activeIndex = orderedIds.indexOf(activeId)
+ const overIndex = orderedIds.indexOf(overId)
+
+ if (activeIndex === -1 || overIndex === -1) {
+ return orderedIds
+ }
+
+ // Read off the list rather than off `movingIds`, so the block keeps the
+ // order it has on screen no matter what order it was selected in.
+ const block = orderedIds.filter((id) => moving.has(id))
+ const rest = orderedIds.filter((id) => !moving.has(id))
+
+ const targetIndex = rest.indexOf(overId)
+ const insertAt = activeIndex < overIndex ? targetIndex + 1 : targetIndex
+
+ return [...rest.slice(0, insertAt), ...block, ...rest.slice(insertAt)]
+}
diff --git a/src/app/list-selection.test.ts b/src/app/list-selection.test.ts
new file mode 100644
index 0000000..221835d
--- /dev/null
+++ b/src/app/list-selection.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ extendSelection,
+ pruneSelection,
+ replaceSelection,
+ toggleSelection
+} from './list-selection'
+
+const ids = ['a', 'b', 'c', 'd', 'e']
+
+describe('replaceSelection', () => {
+ it('selects the one row and anchors on it', () => {
+ expect(replaceSelection('c')).toEqual({ anchorId: 'c', ids: ['c'] })
+ })
+})
+
+describe('toggleSelection', () => {
+ it('adds a row and moves the anchor to it', () => {
+ expect(toggleSelection({ anchorId: 'a', ids: ['a'] }, 'c')).toEqual({
+ anchorId: 'c',
+ ids: ['a', 'c']
+ })
+ })
+
+ it('removes a row that was already selected', () => {
+ expect(
+ toggleSelection({ anchorId: 'a', ids: ['a', 'b', 'c'] }, 'b')
+ ).toEqual({ anchorId: 'b', ids: ['a', 'c'] })
+ })
+
+ it('clears the selection when it removes the last row', () => {
+ expect(toggleSelection({ anchorId: 'a', ids: ['a'] }, 'a')).toEqual(null)
+ })
+
+ it('starts a selection when there is none', () => {
+ expect(toggleSelection(null, 'c')).toEqual({ anchorId: 'c', ids: ['c'] })
+ })
+})
+
+describe('extendSelection', () => {
+ it('selects the range from the anchor forwards, in list order', () => {
+ expect(extendSelection({ anchorId: 'b', ids: ['b'] }, 'd', ids)).toEqual({
+ anchorId: 'b',
+ ids: ['b', 'c', 'd']
+ })
+ })
+
+ it('selects the range from the anchor backwards, in list order', () => {
+ expect(extendSelection({ anchorId: 'd', ids: ['d'] }, 'b', ids)).toEqual({
+ anchorId: 'd',
+ ids: ['b', 'c', 'd']
+ })
+ })
+
+ it('replaces the range instead of growing it when extended twice', () => {
+ const first = extendSelection({ anchorId: 'b', ids: ['b'] }, 'd', ids)
+ const second = extendSelection(first, 'c', ids)
+
+ expect(second).toEqual({ anchorId: 'b', ids: ['b', 'c'] })
+ })
+
+ it('selects the one row when there is no selection to extend', () => {
+ expect(extendSelection(null, 'c', ids)).toEqual({
+ anchorId: 'c',
+ ids: ['c']
+ })
+ })
+
+ it('re-anchors on the clicked row when the anchor has left the list', () => {
+ expect(
+ extendSelection({ anchorId: 'gone', ids: ['gone'] }, 'c', ids)
+ ).toEqual({ anchorId: 'c', ids: ['c'] })
+ })
+
+ it('changes nothing when the clicked row has left the list', () => {
+ const selection = { anchorId: 'b', ids: ['b'] }
+
+ expect(extendSelection(selection, 'gone', ids)).toEqual(selection)
+ })
+})
+
+describe('pruneSelection', () => {
+ it('drops ids that have left the list', () => {
+ expect(pruneSelection({ anchorId: 'a', ids: ['a', 'gone'] }, ids)).toEqual({
+ anchorId: 'a',
+ ids: ['a']
+ })
+ })
+
+ it('clears the selection when nothing is left', () => {
+ expect(pruneSelection({ anchorId: 'gone', ids: ['gone'] }, ids)).toEqual(
+ null
+ )
+ })
+
+ it('re-anchors on the first surviving row when the anchor has left', () => {
+ expect(
+ pruneSelection({ anchorId: 'gone', ids: ['b', 'gone', 'c'] }, ids)
+ ).toEqual({ anchorId: 'b', ids: ['b', 'c'] })
+ })
+
+ it('leaves a selection whose rows all still exist alone', () => {
+ const selection = { anchorId: 'b', ids: ['b', 'c'] }
+
+ expect(pruneSelection(selection, ids)).toEqual(selection)
+ })
+
+ it('answers with nothing when there is no selection', () => {
+ expect(pruneSelection(null, ids)).toEqual(null)
+ })
+})
diff --git a/src/app/list-selection.ts b/src/app/list-selection.ts
new file mode 100644
index 0000000..8f51814
--- /dev/null
+++ b/src/app/list-selection.ts
@@ -0,0 +1,107 @@
+/**
+ * A multi-row selection in a list: the rows themselves, plus the row a
+ * shift-click measures its range from. `null` stands for no selection at all,
+ * so an empty `ids` is unrepresentable rather than something every reader has
+ * to rule out.
+ */
+export interface ListSelection {
+ anchorId: string
+ ids: string[]
+}
+
+/**
+ * Shift-click: the range from the anchor to the clicked row, in list order.
+ * The anchor stays put, so shift-clicking again re-measures from the same row
+ * instead of growing the range one click at a time.
+ *
+ * With nothing to measure from — no selection, or an anchor whose row has
+ * since gone — the clicked row becomes the new anchor and the whole selection.
+ */
+export function extendSelection(
+ selection: ListSelection | null,
+ id: string,
+ orderedIds: string[]
+): ListSelection | null {
+ const index = orderedIds.indexOf(id)
+
+ // A click can only land on a rendered row, so this is drift rather than a
+ // case: the row went away between the render and the click. Leaving the
+ // selection alone is the honest answer.
+ if (index === -1) {
+ return selection
+ }
+
+ const anchorIndex = selection ? orderedIds.indexOf(selection.anchorId) : -1
+
+ if (!selection || anchorIndex === -1) {
+ return replaceSelection(id)
+ }
+
+ const from = Math.min(anchorIndex, index)
+ const to = Math.max(anchorIndex, index)
+
+ return {
+ anchorId: selection.anchorId,
+ ids: orderedIds.slice(from, to + 1)
+ }
+}
+
+/**
+ * Drops the rows that are no longer in the list. Called with the selection on
+ * its way out of the store rather than on a schedule, so a worksheet deleted
+ * in another window cannot leave an id behind for a later drag or delete to
+ * act on.
+ */
+export function pruneSelection(
+ selection: ListSelection | null,
+ existingIds: string[]
+): ListSelection | null {
+ if (!selection) {
+ return null
+ }
+
+ const existing = new Set(existingIds)
+ const ids = selection.ids.filter((id) => existing.has(id))
+
+ const anchorId = existing.has(selection.anchorId)
+ ? selection.anchorId
+ : ids[0]
+
+ if (anchorId === undefined) {
+ return null
+ }
+
+ return { anchorId, ids }
+}
+
+/** A plain click: this row, and the next shift-click measures from it. */
+export function replaceSelection(id: string): ListSelection {
+ return { anchorId: id, ids: [id] }
+}
+
+/**
+ * Command- or control-click: adds the row or takes it out again. Either way it
+ * becomes the anchor — it is the row the user last pointed at, so it is what a
+ * shift-click after it should measure from. Taking out the last row leaves no
+ * selection rather than an empty one.
+ */
+export function toggleSelection(
+ selection: ListSelection | null,
+ id: string
+): ListSelection | null {
+ if (!selection) {
+ return replaceSelection(id)
+ }
+
+ if (!selection.ids.includes(id)) {
+ return { anchorId: id, ids: [...selection.ids, id] }
+ }
+
+ const ids = selection.ids.filter((selectedId) => selectedId !== id)
+
+ if (ids.length === 0) {
+ return null
+ }
+
+ return { anchorId: id, ids }
+}
diff --git a/src/app/store/editor-slice.test.ts b/src/app/store/editor-slice.test.ts
index f515530..fbf3664 100644
--- a/src/app/store/editor-slice.test.ts
+++ b/src/app/store/editor-slice.test.ts
@@ -6,13 +6,20 @@ import reducer, {
worksheetRenameDraftUpdated,
worksheetRenameEnded,
worksheetRenameStarted,
- worksheetSearchQueryUpdated
+ worksheetSearchQueryUpdated,
+ worksheetSelectionChanged
} from './editor-slice'
const initialState: EditorState = {
databaseSearchQuery: '',
worksheetRename: null,
- worksheetSearchQuery: ''
+ worksheetSearchQuery: '',
+ worksheetSelection: null
+}
+
+const selecting: EditorState = {
+ ...initialState,
+ worksheetSelection: { anchorId: 'ws-1', ids: ['ws-1', 'ws-2'] }
}
const renaming: EditorState = {
@@ -119,7 +126,8 @@ describe('editorSlice', () => {
{
databaseSearchQuery: 'pagila',
worksheetRename: renaming.worksheetRename,
- worksheetSearchQuery: 'rev'
+ worksheetSearchQuery: 'rev',
+ worksheetSelection: null
},
worksheetRenameEnded()
)
@@ -127,8 +135,40 @@ describe('editorSlice', () => {
expect(state).toEqual({
databaseSearchQuery: 'pagila',
worksheetRename: null,
- worksheetSearchQuery: 'rev'
+ worksheetSearchQuery: 'rev',
+ worksheetSelection: null
})
})
})
+
+ describe('worksheetSelectionChanged', () => {
+ it('stores the rows the list acts on together', () => {
+ const state = reducer(
+ initialState,
+ worksheetSelectionChanged({ anchorId: 'ws-1', ids: ['ws-1', 'ws-2'] })
+ )
+
+ expect(state).toEqual(selecting)
+ })
+
+ // The selection is computed by the surface that owns the list, so its
+ // "nothing is picked out" travels through the same action rather than a
+ // second one that would have to be kept in step with it.
+ it('clears the selection when handed nothing', () => {
+ const state = reducer(selecting, worksheetSelectionChanged(null))
+
+ expect(state).toEqual(initialState)
+ })
+ })
+
+ describe('filtering', () => {
+ // A filtered list cannot be dragged and its hidden rows cannot be seen, so
+ // a selection surviving the change would act on rows the user is no longer
+ // looking at.
+ it('clears the selection when the worksheet filter changes', () => {
+ const state = reducer(selecting, worksheetSearchQueryUpdated('rev'))
+
+ expect(state).toEqual({ ...initialState, worksheetSearchQuery: 'rev' })
+ })
+ })
})
diff --git a/src/app/store/editor-slice.ts b/src/app/store/editor-slice.ts
index 7107af1..111f3b8 100644
--- a/src/app/store/editor-slice.ts
+++ b/src/app/store/editor-slice.ts
@@ -1,5 +1,7 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
+import { type ListSelection } from '../list-selection'
+
// Which surface opened the rename input. The session is shared, so this is how
// the sidebar list and the tab strip each tell "I am the one being edited"
// apart from "someone else is being edited" — the second of which still has to
@@ -12,18 +14,23 @@ export interface WorksheetRenameSession {
worksheetId: string
}
-// Which worksheet is open now lives in the tabs slice — `tabOpened` both opens
-// and activates, so there is no separate "selected" concept any more.
+// Which worksheet is open lives in the tabs slice — `tabOpened` both opens and
+// activates it. `worksheetSelection` is a different thing: the rows the sidebar
+// list acts on together, which is usually the open one and only differs once
+// the user command- or shift-clicks. `null` is the ordinary case of nothing
+// picked out.
export interface EditorState {
databaseSearchQuery: string
worksheetRename: WorksheetRenameSession | null
worksheetSearchQuery: string
+ worksheetSelection: ListSelection | null
}
const initialState: EditorState = {
databaseSearchQuery: '',
worksheetRename: null,
- worksheetSearchQuery: ''
+ worksheetSearchQuery: '',
+ worksheetSelection: null
}
const editorSlice = createSlice({
@@ -57,6 +64,21 @@ const editorSlice = createSlice({
},
worksheetSearchQueryUpdated: (state, action: PayloadAction) => {
state.worksheetSearchQuery = action.payload
+
+ // Filtered rows cannot be dragged and hidden ones cannot be seen, so a
+ // selection that outlived the query would let the next delete act on
+ // rows the user is no longer looking at.
+ state.worksheetSelection = null
+ },
+ // The whole selection, computed by the surface that owns the list: the
+ // range a shift-click covers depends on the order the rows are in and on
+ // which one is open, neither of which is in this slice. `null` is nothing
+ // picked out.
+ worksheetSelectionChanged: (
+ state,
+ action: PayloadAction
+ ) => {
+ state.worksheetSelection = action.payload
}
}
})
@@ -66,7 +88,8 @@ export const {
worksheetRenameDraftUpdated,
worksheetRenameEnded,
worksheetRenameStarted,
- worksheetSearchQueryUpdated
+ worksheetSearchQueryUpdated,
+ worksheetSelectionChanged
} = editorSlice.actions
export default editorSlice.reducer
diff --git a/src/app/test-utils.tsx b/src/app/test-utils.tsx
index 5246bb1..c2b58d2 100644
--- a/src/app/test-utils.tsx
+++ b/src/app/test-utils.tsx
@@ -118,7 +118,8 @@ function createEditorState(options: RenderOptions): EditorState {
return {
databaseSearchQuery: options.editor?.databaseSearchQuery ?? '',
worksheetRename: options.editor?.worksheetRename ?? null,
- worksheetSearchQuery: options.editor?.worksheetSearchQuery ?? ''
+ worksheetSearchQuery: options.editor?.worksheetSearchQuery ?? '',
+ worksheetSelection: options.editor?.worksheetSelection ?? null
}
}