From c0d4072aab56568a7cbeb3374d18a2517b4e1b29 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 3 Aug 2026 09:48:56 -0700 Subject: [PATCH 1/3] fix(scripting): sort selected devices by name when added --- frontend/src/components/DeviceListHeaderCheckbox.tsx | 3 ++- frontend/src/helpers/selectionRange.ts | 8 ++++++++ frontend/src/hooks/useSelect.ts | 12 +++++++++--- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/DeviceListHeaderCheckbox.tsx b/frontend/src/components/DeviceListHeaderCheckbox.tsx index 574d7c4e5..bfb7eb9d1 100644 --- a/frontend/src/components/DeviceListHeaderCheckbox.tsx +++ b/frontend/src/components/DeviceListHeaderCheckbox.tsx @@ -2,6 +2,7 @@ import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { State, Dispatch } from '../store' import { Checkbox } from '@mui/material' +import { sortSelectedIds } from '../helpers/selectionRange' import { Icon } from './Icon' type Props = { select?: boolean; devices: IDevice[] } @@ -16,7 +17,7 @@ export const DeviceListHeaderCheckbox: React.FC = ({ select, devices }) = const onClick = event => { event.stopPropagation() if (indeterminate || selected.length === 0) { - dispatch.ui.set({ selected: devices.map(d => d.id) }) + dispatch.ui.set({ selected: sortSelectedIds(devices.map(d => d.id), devices) }) } else { dispatch.ui.set({ selected: [], selectionAnchor: undefined }) } diff --git a/frontend/src/helpers/selectionRange.ts b/frontend/src/helpers/selectionRange.ts index 373e3a388..49daf3a30 100644 --- a/frontend/src/helpers/selectionRange.ts +++ b/frontend/src/helpers/selectionRange.ts @@ -24,6 +24,14 @@ export function mergeSelectedIds(selected: string[], idsToAdd: string[]) { return [...new Set([...selected, ...idsToAdd])] } +// Keeps the selection in name order from the first click, so it never reorders downstream. +export function sortSelectedIds(selected: string[], devices: IDevice[]) { + const names = new Map(devices.map(device => [device.id, device.name])) + return [...selected].sort((a, b) => + (names.get(a) || a).localeCompare(names.get(b) || b, undefined, { numeric: true, sensitivity: 'base' }) + ) +} + export function removeSelectedIds(selected: string[], idsToRemove: string[]) { const remove = new Set(idsToRemove) return selected.filter(id => !remove.has(id)) diff --git a/frontend/src/hooks/useSelect.ts b/frontend/src/hooks/useSelect.ts index 0a396585e..d3542436e 100644 --- a/frontend/src/hooks/useSelect.ts +++ b/frontend/src/hooks/useSelect.ts @@ -1,7 +1,13 @@ import { useDispatch, useSelector, useStore } from 'react-redux' import { Dispatch, State } from '../store' import { selectVisibleDevices } from '../selectors/devices' -import { getInclusiveIdRange, getSelectableDeviceIds, mergeSelectedIds, removeSelectedIds } from '../helpers/selectionRange' +import { + getInclusiveIdRange, + getSelectableDeviceIds, + mergeSelectedIds, + removeSelectedIds, + sortSelectedIds, +} from '../helpers/selectionRange' type UseSelectParams = { deviceId: string @@ -25,7 +31,7 @@ export const useSelect = ({ deviceId, selectMode }: UseSelectParams) => { if (range.length) { const rangeSelected = isSelected ? removeSelectedIds(nextSelected, range) : mergeSelectedIds(nextSelected, range) - dispatch.ui.set({ selected: rangeSelected }) + dispatch.ui.set({ selected: sortSelectedIds(rangeSelected, visibleDevices) }) dispatch.ui.set({ selectionAnchor: deviceId }) return } @@ -37,7 +43,7 @@ export const useSelect = ({ deviceId, selectMode }: UseSelectParams) => { nextSelected.push(deviceId) } - dispatch.ui.set({ selected: nextSelected }) + dispatch.ui.set({ selected: sortSelectedIds(nextSelected, visibleDevices) }) dispatch.ui.set({ selectionAnchor: deviceId }) } From d7c4ba05cea129f91734ea5d3bea31ded50e5497 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 3 Aug 2026 10:16:12 -0700 Subject: [PATCH 2/3] refactor(selection): collapse duplicate select branches and hoist the collator --- frontend/src/helpers/selectionRange.ts | 15 +++++++++++---- frontend/src/hooks/useSelect.ts | 23 ++++------------------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/frontend/src/helpers/selectionRange.ts b/frontend/src/helpers/selectionRange.ts index 49daf3a30..b62d4a6d5 100644 --- a/frontend/src/helpers/selectionRange.ts +++ b/frontend/src/helpers/selectionRange.ts @@ -24,12 +24,19 @@ export function mergeSelectedIds(selected: string[], idsToAdd: string[]) { return [...new Set([...selected, ...idsToAdd])] } -// Keeps the selection in name order from the first click, so it never reorders downstream. +// Reused across comparisons — localeCompare with options builds a new collator on every call. +const nameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) + +// Devices are selected in click order, so re-sort by name on each change to keep the +// selection ordered. Ids with no loaded device sort last rather than interleaving by raw id. export function sortSelectedIds(selected: string[], devices: IDevice[]) { const names = new Map(devices.map(device => [device.id, device.name])) - return [...selected].sort((a, b) => - (names.get(a) || a).localeCompare(names.get(b) || b, undefined, { numeric: true, sensitivity: 'base' }) - ) + return [...selected].sort((a, b) => { + const nameA = names.get(a) + const nameB = names.get(b) + if (!nameA || !nameB) return nameA ? -1 : nameB ? 1 : 0 + return nameCollator.compare(nameA, nameB) + }) } export function removeSelectedIds(selected: string[], idsToRemove: string[]) { diff --git a/frontend/src/hooks/useSelect.ts b/frontend/src/hooks/useSelect.ts index d3542436e..91288c6f2 100644 --- a/frontend/src/hooks/useSelect.ts +++ b/frontend/src/hooks/useSelect.ts @@ -23,28 +23,13 @@ export const useSelect = ({ deviceId, selectMode }: UseSelectParams) => { const handleSelect = (shiftKey?: boolean) => { const state = store.getState() const selected = state.ui.selected - const selectionAnchor = state.ui.selectionAnchor const visibleDevices = selectVisibleDevices(state) - const nextSelected = [...selected] const selectableIds = getSelectableDeviceIds(visibleDevices) - const range = shiftKey ? getInclusiveIdRange(selectableIds, selectionAnchor, deviceId) : [] + const range = shiftKey ? getInclusiveIdRange(selectableIds, state.ui.selectionAnchor, deviceId) : [] + const ids = range.length ? range : [deviceId] + const nextSelected = isSelected ? removeSelectedIds(selected, ids) : mergeSelectedIds(selected, ids) - if (range.length) { - const rangeSelected = isSelected ? removeSelectedIds(nextSelected, range) : mergeSelectedIds(nextSelected, range) - dispatch.ui.set({ selected: sortSelectedIds(rangeSelected, visibleDevices) }) - dispatch.ui.set({ selectionAnchor: deviceId }) - return - } - - if (isSelected) { - const index = nextSelected.indexOf(deviceId) - nextSelected.splice(index, 1) - } else { - nextSelected.push(deviceId) - } - - dispatch.ui.set({ selected: sortSelectedIds(nextSelected, visibleDevices) }) - dispatch.ui.set({ selectionAnchor: deviceId }) + dispatch.ui.set({ selected: sortSelectedIds(nextSelected, visibleDevices), selectionAnchor: deviceId }) } return { isSelected, isAnchorRow, handleSelect } From 230d32074ab90056d1e4374bf12518a7ca37b2e5 Mon Sep 17 00:00:00 2001 From: Jamie Ruderman Date: Mon, 3 Aug 2026 12:27:10 -0700 Subject: [PATCH 3/3] refactor(selection): order the selection by list position instead of by name --- .../src/components/DeviceListHeaderCheckbox.tsx | 3 +-- frontend/src/helpers/selectionRange.ts | 17 +++++------------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/DeviceListHeaderCheckbox.tsx b/frontend/src/components/DeviceListHeaderCheckbox.tsx index bfb7eb9d1..574d7c4e5 100644 --- a/frontend/src/components/DeviceListHeaderCheckbox.tsx +++ b/frontend/src/components/DeviceListHeaderCheckbox.tsx @@ -2,7 +2,6 @@ import React from 'react' import { useSelector, useDispatch } from 'react-redux' import { State, Dispatch } from '../store' import { Checkbox } from '@mui/material' -import { sortSelectedIds } from '../helpers/selectionRange' import { Icon } from './Icon' type Props = { select?: boolean; devices: IDevice[] } @@ -17,7 +16,7 @@ export const DeviceListHeaderCheckbox: React.FC = ({ select, devices }) = const onClick = event => { event.stopPropagation() if (indeterminate || selected.length === 0) { - dispatch.ui.set({ selected: sortSelectedIds(devices.map(d => d.id), devices) }) + dispatch.ui.set({ selected: devices.map(d => d.id) }) } else { dispatch.ui.set({ selected: [], selectionAnchor: undefined }) } diff --git a/frontend/src/helpers/selectionRange.ts b/frontend/src/helpers/selectionRange.ts index b62d4a6d5..4d4e885c4 100644 --- a/frontend/src/helpers/selectionRange.ts +++ b/frontend/src/helpers/selectionRange.ts @@ -24,19 +24,12 @@ export function mergeSelectedIds(selected: string[], idsToAdd: string[]) { return [...new Set([...selected, ...idsToAdd])] } -// Reused across comparisons — localeCompare with options builds a new collator on every call. -const nameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) - -// Devices are selected in click order, so re-sort by name on each change to keep the -// selection ordered. Ids with no loaded device sort last rather than interleaving by raw id. +// Devices are selected in click order, so re-order on each change to follow the list the user +// is looking at — which respects whatever sort they've chosen. Ids no longer in the list (a +// selection outliving a filter change) keep their relative order at the end. export function sortSelectedIds(selected: string[], devices: IDevice[]) { - const names = new Map(devices.map(device => [device.id, device.name])) - return [...selected].sort((a, b) => { - const nameA = names.get(a) - const nameB = names.get(b) - if (!nameA || !nameB) return nameA ? -1 : nameB ? 1 : 0 - return nameCollator.compare(nameA, nameB) - }) + const order = new Map(devices.map((device, index) => [device.id, index])) + return [...selected].sort((a, b) => (order.get(a) ?? Infinity) - (order.get(b) ?? Infinity)) } export function removeSelectedIds(selected: string[], idsToRemove: string[]) {