[FE-4198] feat(studio): select a range of logs with shift-click (#50381)

Shift-clicking a log row checkbox now selects every row between the last
clicked row and the clicked one, so you can grab a consecutive block of
logs to copy without checking each one. Applies everywhere the shared
`LogTable` renders: Postgres/API/Auth/Edge Functions logs and the Logs
Explorer.

**Added:**
- `getShiftClickSelection` in `Logs.utils.ts`: pure helper that computes
the next selection from the ordered row keys, the current selection, the
anchor row, and the clicked row. Adds the inclusive range in either
direction. If the whole range is already selected it deselects the range
instead. Falls back to a plain toggle when there's no usable anchor.
Covered by unit tests, plus `LogTable` component tests for range select,
the no-anchor fallback, anchor clearing, and range deselect.

**Changed:**
- `LogTable` tracks the last toggled row as the range anchor (a ref,
since it's only read in handlers). The anchor is set by plain clicks,
shift-clicks, and the Shift+Space row toggle, and cleared whenever the
selection becomes empty (toggling off the last row, plain row click,
Escape, action bar clear, select-all then deselect-all, or a new query
loading).
- The checkbox cell handles `onClick` instead of `onCheckedChange` so
the shift key is available. Keyboard Space on a focused checkbox still
toggles it, since Radix dispatches a click for it.
- A shift mousedown on the checkbox cell is prevented so the browser
doesn't start a text selection across rows.

Unified Logs has its own row selection (TanStack Table) and is not
changed here.

## To test

- Open any log page with a decent number of rows, e.g. Postgres logs.
Click one checkbox, then shift-click a checkbox several rows below.
Every row in between should be checked and the action bar should show
the count. Repeat upward.
- Shift-click a range that's already fully selected: the range should
clear, and rows outside it stay as they were.
- Plain-click a row's message text (not the checkbox): side panel opens
and the selection clears. A following shift-click should just toggle
that one row.
- Press Escape or the action bar's clear button, then shift-click: also
just a single toggle.
- Focus a row with the arrow keys, press Shift+Space, then shift-click a
lower checkbox: the range should extend from the keyboard-toggled row.
- Tab to a checkbox and press Space: it should still toggle.
- After a shift-click, confirm no text is highlighted across the rows.
- Copy as JSON/Markdown/CSV still copies the selected rows in display
order.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added shift-click range selection to the logs table for selecting or
deselecting consecutive rows.
  - Preserved single-row selection when range selection is unavailable.
- Improved selection behavior when clearing selections or changing log
queries, preventing stale range anchors.

- **Tests**
- Added coverage for forward and reverse range selection, deselection,
partial selections, fallback behavior, and input immutability.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
Alaister YoungandAlaister Young authored and GitHub committed 2026-09-16 18:18:28 +08:00
1 parent 99be7f92ce
commit 240bfce7f6
4 files changed
+278 -8

No files matched your search

@@ -30,6 +30,7 @@ import {
formatLogsAsCsv,
formatLogsAsJson,
formatLogsAsMarkdown,
getShiftClickSelection,
isDefaultLogPreviewFormat,
} from './Logs.utils'
import LogSelection from './LogSelection'
@@ -106,6 +107,8 @@ export const LogTable = ({
const [selectedLogId] = useSelectedLog()
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
// Last row key the user toggled, used as the start of a shift-click range
const selectionAnchorRef = useRef<string | null>(null)
const [copiedFormat, setCopiedFormat] = useState<LogCopyFormat | null>(null)
const triggerRef = useRef<HTMLDivElement>(null)
const [activeRow, setActiveRow] = useState<LogData | null>(null)
@@ -196,13 +199,24 @@ export const LogTable = ({
minWidth: 32,
renderCell: ({ row }) => {
const key = getRowKey(row)
const toggle = () => {
const next = new Set(selectedRows)
if (next.has(key)) {
next.delete(key)
const toggle = (isShiftClick: boolean) => {
let next: Set<string>
if (isShiftClick) {
next = getShiftClickSelection({
orderedKeys: logDataRows.map(getRowKey),
selectedKeys: selectedRows,
anchorKey: selectionAnchorRef.current,
targetKey: key,
})
} else {
next.add(key)
next = new Set(selectedRows)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
}
selectionAnchorRef.current = next.size > 0 ? key : null
setSelectedRows(next)
if (next.size > 0) {
setSelectedRow(null)
@@ -212,16 +226,23 @@ export const LogTable = ({
return (
<div
className="absolute group inset-0 flex justify-center px-2 items-center cursor-pointer"
// Prevent a shift-click from starting a browser text selection across rows
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault()
}}
onClick={(e) => {
e.stopPropagation()
toggle()
toggle(e.shiftKey)
}}
>
<Checkbox
className="group-hover:border-foreground-muted"
checked={selectedRows.has(key)}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
onCheckedChange={toggle}
// use onClick instead of onCheckedChange so the shift key is available for range selection
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
toggle(e.shiftKey)
}}
/>
</div>
)
@@ -292,6 +313,7 @@ export const LogTable = ({
(row: LogData) => {
// Regular single click — clear multi-select, open side panel
setSelectedRows(new Set())
selectionAnchorRef.current = null
setSelectedRow(row)
onSelectedLogChange?.(row)
},
@@ -367,6 +389,7 @@ export const LogTable = ({
() => {
if (selectedRows.size === logDataRows.length) {
setSelectedRows(new Set())
selectionAnchorRef.current = null
} else {
setSelectedRows(new Set(logDataRows.map((row) => getRowKey(row))))
setSelectedRow(null)
@@ -387,6 +410,7 @@ export const LogTable = ({
} else {
next.add(key)
}
selectionAnchorRef.current = next.size > 0 ? key : null
setSelectedRows(next)
},
{ enabled: selectedRow !== null }
@@ -405,6 +429,7 @@ export const LogTable = ({
SHORTCUT_IDS.LOGS_PREVIEW_EXIT_SELECTION,
() => {
setSelectedRows(new Set())
selectionAnchorRef.current = null
;(document.activeElement as HTMLElement | null)?.blur()
},
{ enabled: !selectionOpen && selectedRows.size > 0 }
@@ -427,6 +452,7 @@ export const LogTable = ({
useEffect(() => {
if (isLoading) {
setSelectedRows(new Set())
selectionAnchorRef.current = null
}
}, [isLoading])
@@ -583,6 +609,7 @@ export const LogTable = ({
sqlQuery={sqlQuery}
onClear={() => {
setSelectedRows(new Set())
selectionAnchorRef.current = null
}}
/>
</div>
@@ -13,6 +13,7 @@ import {
genChartQuery,
genDefaultQuery,
getAuthLogSeverity,
getShiftClickSelection,
parseMultigresEventMessage,
} from './Logs.utils'
@@ -351,4 +352,120 @@ describe('Logs.utils', () => {
).not.toThrow()
})
})
describe('getShiftClickSelection', () => {
const orderedKeys = ['a', 'b', 'c', 'd', 'e']
test('selects the range downward from the anchor', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['b']),
anchorKey: 'b',
targetKey: 'd',
})
expect([...result].sort()).toEqual(['b', 'c', 'd'])
})
test('selects the range upward from the anchor', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['d']),
anchorKey: 'd',
targetKey: 'b',
})
expect([...result].sort()).toEqual(['b', 'c', 'd'])
})
test('toggles just the anchor when it is also the target', () => {
const added = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(),
anchorKey: 'c',
targetKey: 'c',
})
expect([...added]).toEqual(['c'])
const removed = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['c']),
anchorKey: 'c',
targetKey: 'c',
})
expect([...removed]).toEqual([])
})
test('removes the range when it is already fully selected, keeping keys outside it', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a', 'b', 'c', 'd']),
anchorKey: 'b',
targetKey: 'd',
})
expect([...result].sort()).toEqual(['a'])
})
test('selects the whole range when it is only partially selected', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['c']),
anchorKey: 'b',
targetKey: 'd',
})
expect([...result].sort()).toEqual(['b', 'c', 'd'])
})
test('falls back to adding the target when there is no anchor', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a']),
anchorKey: null,
targetKey: 'c',
})
expect([...result].sort()).toEqual(['a', 'c'])
})
test('falls back to removing the target when there is no anchor and it is selected', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a', 'c']),
anchorKey: null,
targetKey: 'c',
})
expect([...result].sort()).toEqual(['a'])
})
test('falls back to a plain toggle when the anchor is no longer in the rows', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(),
anchorKey: 'gone',
targetKey: 'd',
})
expect([...result]).toEqual(['d'])
})
test('falls back to a plain toggle when the target is not in the rows', () => {
const result = getShiftClickSelection({
orderedKeys,
selectedKeys: new Set(['a']),
anchorKey: 'a',
targetKey: 'gone',
})
expect([...result].sort()).toEqual(['a', 'gone'])
})
test('does not mutate its inputs', () => {
const selectedKeys = new Set(['b'])
const keys = [...orderedKeys]
const result = getShiftClickSelection({
orderedKeys: keys,
selectedKeys,
anchorKey: 'b',
targetKey: 'd',
})
expect(result).not.toBe(selectedKeys)
expect([...selectedKeys]).toEqual(['b'])
expect(keys).toEqual(orderedKeys)
})
})
})
@@ -957,3 +957,52 @@ export function buildLogsPrompt(rows: LogData[], queryType?: string, sqlQuery?:
'\n\nWhat do these logs indicate? What steps can I take to resolve it? Keep your answer very concise and actionable. Max 2 or 3 bullet points.'
)
}
/**
* Computes the next multi-select set after a shift-click on `targetKey`, extending
* the selection from `anchorKey` (the last row the user clicked). Every key between
* anchor and target (inclusive, in `orderedKeys` order) is added. If the whole range is
* already selected, the range is removed instead. Falls back to a plain toggle of
* `targetKey` when there is no usable anchor (null, or no longer in `orderedKeys`).
*/
export function getShiftClickSelection({
orderedKeys,
selectedKeys,
anchorKey,
targetKey,
}: {
orderedKeys: string[]
selectedKeys: Set<string>
anchorKey: string | null
targetKey: string
}): Set<string> {
const next = new Set(selectedKeys)
const anchorIndex = anchorKey === null ? -1 : orderedKeys.indexOf(anchorKey)
const targetIndex = orderedKeys.indexOf(targetKey)
const hasUsableAnchor = anchorIndex !== -1 && targetIndex !== -1
if (!hasUsableAnchor) {
if (next.has(targetKey)) {
next.delete(targetKey)
} else {
next.add(targetKey)
}
return next
}
const startIndex = Math.min(anchorIndex, targetIndex)
const endIndex = Math.max(anchorIndex, targetIndex)
const rangeKeys = orderedKeys.slice(startIndex, endIndex + 1)
const isRangeFullySelected = rangeKeys.every((key) => selectedKeys.has(key))
rangeKeys.forEach((key) => {
if (isRangeFullySelected) {
next.delete(key)
} else {
next.add(key)
}
})
return next
}
@@ -307,3 +307,80 @@ test('custom error message: Resources exceeded during query execution', async ()
await screen.findByText(/Avoid querying across a large datetime range/)
await screen.findByText(/Please contact support if this error persists/)
})
const createLogRow = (index: number) => ({
id: `log-id-${index}`,
timestamp: fakeMicroTimestamp - index,
event_message: `event message ${index}`,
})
const MULTI_SELECT_LOGS = Array.from({ length: 6 }, (_, index) => createLogRow(index))
const getRowCheckboxes = () => screen.getAllByRole('checkbox')
const expectCheckedIndexes = (checkedIndexes: number[]) => {
const checkboxes = getRowCheckboxes()
checkboxes.forEach((checkbox, index) => {
expect(checkbox.getAttribute('aria-checked')).toBe(
checkedIndexes.includes(index) ? 'true' : 'false'
)
})
}
const shiftClick = async (user: ReturnType<typeof userEvent.setup>, element: Element) => {
await user.keyboard('{Shift>}')
await user.click(element)
await user.keyboard('{/Shift}')
}
test('shift-click selects the range between the anchor row and the clicked row', async () => {
const user = userEvent.setup()
render(<LogTable projectRef="projectRef" data={MULTI_SELECT_LOGS} />)
const checkboxes = getRowCheckboxes()
expect(checkboxes).toHaveLength(MULTI_SELECT_LOGS.length)
await user.click(checkboxes[1])
await shiftClick(user, getRowCheckboxes()[4])
expectCheckedIndexes([1, 2, 3, 4])
await screen.findByText('4 rows selected')
})
test('shift-click without an anchor toggles only the clicked row', async () => {
const user = userEvent.setup()
render(<LogTable projectRef="projectRef" data={MULTI_SELECT_LOGS} />)
await shiftClick(user, getRowCheckboxes()[3])
expectCheckedIndexes([3])
await screen.findByText('1 row selected')
})
test('clears the shift-click anchor once the selection becomes empty', async () => {
const user = userEvent.setup()
render(<LogTable projectRef="projectRef" data={MULTI_SELECT_LOGS} />)
await user.click(getRowCheckboxes()[1])
await user.click(getRowCheckboxes()[1])
expectCheckedIndexes([])
await shiftClick(user, getRowCheckboxes()[4])
expectCheckedIndexes([4])
await screen.findByText('1 row selected')
})
test('shift-click deselects a range that is already fully selected', async () => {
const user = userEvent.setup()
render(<LogTable projectRef="projectRef" data={MULTI_SELECT_LOGS} />)
await user.click(getRowCheckboxes()[1])
await shiftClick(user, getRowCheckboxes()[4])
expectCheckedIndexes([1, 2, 3, 4])
await shiftClick(user, getRowCheckboxes()[1])
expectCheckedIndexes([])
await waitFor(() => expect(screen.queryByText(/rows? selected/)).toBeNull())
})