feat(log-explorer): arrow key and deeper shortcuts for log explorer (#45989)

Closes
[FE-3378](https://linear.app/supabase/issue/FE-3378/featlogs-keyboard-shortcuts-for-function-logs-invocations-and-logs).

## Summary
Adds a shared shortcut registry for every `LogsPreviewer` surface —
Function Logs, Function Invocations, and the Logs Explorer — and brings
the grid keyboard model in line with the Auth Users / Table Editor
patterns.

## Shortcuts

| Key | Action |
| --- | --- |
| `↑` / `↓` | Move single-row selection; opens side panel |
| `Shift+Space` | Toggle current row in multi-select |
| `Mod+A` | Toggle all visible rows in multi-select |
| `Esc` | Staged: clear multi-select → close side panel |
| `Shift+R` | Refresh logs |
| `Shift+H` | Toggle histogram |
| `Shift+L` | Load older logs |
| `Shift+P` | Open time range picker |
| `Mod+Shift+J / M / C` | Copy selected rows as JSON / Markdown / CSV
(existing global handler) |

## Other changes
- `ShortcutTooltip` on search, refresh, histogram, load older, and
time-picker controls.
- `onSearchInputEscape` wired on the logs search bar (clear → blur).
- Visual row highlight (`rdg-row--focused`) when a row is
keyboard-focused or multi-selected.
- Multi-select copy dropdown gains a **Copy as CSV** entry and shows the
keybind on each item via `ShortcutBadge`.
- Manual arrow-nav (`navigate()`) updates `selectedRow` directly without
going through `onRowClick`, so multi-select checkmarks survive keyboard
navigation.

## Test plan
- [x] Function Logs and Function Invocations: all shortcuts above fire
while the page is mounted, no firing in other tabs.
- [x] Logs Explorer: same shortcuts work; copy keybinds still copy *all*
rows when nothing is multi-selected.
- [x] Arrow keys on first load select the first row even when the focus
sink is the active element.
- [x] Selecting rows via checkbox or `Shift+Space`, then pressing arrow
keys, preserves the checkmarks.
- [x] Escape on a populated search input clears it; Escape on an empty
input blurs it.
- [x] Esc with multi-select active clears the selection before closing
the side panel.

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

* **New Features**
* CSV export for log selections (adds CSV alongside JSON and Markdown).
* New logs-preview keyboard shortcuts: search focus, refresh, chart
toggle, date picker, load older, navigation, and selection.

* **Improvements**
  * Shortcut badges and tooltip integration across the logs UI.
* Search input focus/ref support and controlled date-picker visibility.
  * Better no-results/error rendering and expanded copy dropdown sizing.

* **Tests**
  * Added CSV formatting tests covering RFC 4180 edge cases.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45989)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-05-19 15:25:47 +00:00
1 parent 607cce2bc1
commit 83992c55f7
13 files changed
+461 -106

No files matched your search

@@ -1,7 +1,6 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { ContextMenuContent } from '@ui/components/shadcn/ui/context-menu'
import { IS_PLATFORM, useParams } from 'common'
import { isEqual } from 'lodash'
import { Copy, Eye, EyeOff, Play } from 'lucide-react'
import { Key, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import DataGrid, { Column, RenderRowProps, Row } from 'react-data-grid'
@@ -26,17 +25,24 @@ import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewCol
import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender'
import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender'
import type { LogData, LogQueryError, QueryType } from './Logs.types'
import { formatLogsAsJson, formatLogsAsMarkdown, isDefaultLogPreviewFormat } from './Logs.utils'
import {
formatLogsAsCsv,
formatLogsAsJson,
formatLogsAsMarkdown,
isDefaultLogPreviewFormat,
} from './Logs.utils'
import LogSelection from './LogSelection'
import { DefaultErrorRenderer } from './LogsErrorRenderers/DefaultErrorRenderer'
import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer'
import { LogsTableEmptyState } from './LogsTableEmptyState'
import { MultiSelectActionBar } from './MultiSelectActionBar'
import { MultiSelectActionBar, type LogCopyFormat } from './MultiSelectActionBar'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
import { useSelectedLog } from '@/hooks/analytics/useSelectedLog'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useProfile } from '@/lib/profile'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
import type { ResponseError } from '@/types'
interface Props {
@@ -96,7 +102,7 @@ export const LogTable = ({
const [selectedLogId] = useSelectedLog()
const [selectedRow, setSelectedRow] = useState<LogData | null>(null)
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
const [copiedFormat, setCopiedFormat] = useState<'json' | 'markdown' | null>(null)
const [copiedFormat, setCopiedFormat] = useState<LogCopyFormat | null>(null)
const triggerRef = useRef<HTMLDivElement>(null)
const [activeRow, setActiveRow] = useState<LogData | null>(null)
const [contextMenuKey, setContextMenuKey] = useState(0)
@@ -313,28 +319,86 @@ export const LogTable = ({
: String(value)
}
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key === 'Escape') {
// Arrow-key navigation. Unlike mouse-click (`onRowClick`), keyboard nav must
// preserve any existing multi-select checkmarks — clearing `selectedRows`
// here would wipe the user's checked rows the moment they press an arrow.
const navigate = (direction: 'down' | 'up') => {
if (logDataRows.length === 0) return
const focusRow = (row: LogData) => {
setSelectedRow(row)
onSelectedLogChange?.(row)
}
if (!selectedRow) {
focusRow(logDataRows[0])
return
}
const selectedKey = getRowKey(selectedRow)
const currentIdx = logDataRows.findIndex((row) => getRowKey(row) === selectedKey)
if (currentIdx === -1) {
focusRow(logDataRows[0])
return
}
if (direction === 'down' && currentIdx < logDataRows.length - 1) {
focusRow(logDataRows[currentIdx + 1])
} else if (direction === 'up' && currentIdx > 0) {
focusRow(logDataRows[currentIdx - 1])
}
}
useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_DOWN, () => navigate('down'), {
enabled: logDataRows.length > 0,
})
useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_UP, () => navigate('up'), {
enabled: logDataRows.length > 0,
})
useShortcut(
SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ALL_SELECTION,
() => {
if (selectedRows.size === logDataRows.length) {
setSelectedRows(new Set())
return
}
// Arrow navigation only in single-select mode
if (!logDataRows.length || !selectedRow || selectedRows.size > 0) return
const currentIndex = logDataRows.findIndex((row) => isEqual(row, selectedRow))
if (currentIndex === -1) return
if (event.key === 'ArrowUp' && currentIndex > 0) {
const prevRow = logDataRows[currentIndex - 1]
onRowClick(prevRow)
} else if (event.key === 'ArrowDown' && currentIndex < logDataRows.length - 1) {
const nextRow = logDataRows[currentIndex + 1]
onRowClick(nextRow)
} else {
setSelectedRows(new Set(logDataRows.map((row) => getRowKey(row))))
setSelectedRow(null)
onSelectedLogChange?.(null)
}
},
[logDataRows, selectedRow, selectedRows, onRowClick]
{ enabled: logDataRows.length > 0 }
)
useShortcut(
SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ROW_SELECTION,
() => {
if (!selectedRow) return
const key = getRowKey(selectedRow)
const next = new Set(selectedRows)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
setSelectedRows(next)
},
{ enabled: selectedRow !== null }
)
useShortcut(
SHORTCUT_IDS.LOGS_PREVIEW_CLOSE_PANEL,
() => {
onSelectedLogChange?.(null)
setSelectedRow(null)
},
{ enabled: selectionOpen }
)
useShortcut(
SHORTCUT_IDS.LOGS_PREVIEW_EXIT_SELECTION,
() => {
setSelectedRows(new Set())
;(document.activeElement as HTMLElement | null)?.blur()
},
{ enabled: !selectionOpen && selectedRows.size > 0 }
)
useEffect(() => {
@@ -343,13 +407,6 @@ export const LogTable = ({
}
}, [selectedLog, isSelectedLogLoading])
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleKeyDown])
useEffect(() => {
if (!isLoading && !selectedRow) {
const logData = data.find((x) => x.id === selectedLogId)
@@ -371,11 +428,12 @@ export const LogTable = ({
return () => clearTimeout(timer)
}, [copiedFormat])
function handleCopySelectedRows(format: 'json' | 'markdown') {
const text =
format === 'json'
? formatLogsAsJson(selectedRowsData)
: formatLogsAsMarkdown(selectedRowsData)
function handleCopySelectedRows(format: LogCopyFormat) {
let text = ''
if (format === 'json') text = formatLogsAsJson(selectedRowsData)
if (format === 'markdown') text = formatLogsAsMarkdown(selectedRowsData)
if (format === 'csv') text = formatLogsAsCsv(selectedRowsData)
copyToClipboard(text, () => {
setCopiedFormat(format)
toast.success(
@@ -384,7 +442,7 @@ export const LogTable = ({
})
}
const LogsExplorerTableHeader = () => (
const logsExplorerTableHeader = (
<div
className={cn(
'flex w-full items-center justify-between border-t bg-surface-100 px-5 py-2',
@@ -446,7 +504,7 @@ export const LogTable = ({
</div>
)
const RenderErrorAlert = () => {
const renderErrorAlert = () => {
if (!error) return null
const childProps = {
isCustomQuery: queryType ? false : true,
@@ -465,16 +523,16 @@ export const LogTable = ({
)
}
const RenderNoResultAlert = () => {
const renderNoResultAlert = () => {
if (EmptyState) return EmptyState
else return <LogsTableEmptyState />
return <LogsTableEmptyState />
}
if (!data) return null
return (
<section className={'h-full flex w-full flex-col flex-1'}>
{!queryType && <LogsExplorerTableHeader />}
{!queryType && logsExplorerTableHeader}
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel
id="log-table-content"
@@ -536,7 +594,7 @@ export const LogTable = ({
rowClass={(row: LogData) => {
const key = getRowKey(row)
const isMultiSelected = selectedRows.has(key)
const isSingleSelected = isEqual(row, selectedRow)
const isSingleSelected = selectedRow !== null && getRowKey(selectedRow) === key
return cn(
'font-mono tracking-tight bg-studio! hover:bg-surface-100! cursor-pointer',
{
@@ -556,8 +614,8 @@ export const LogTable = ({
// gridColumn: '1 / -1' makes the fallback span all CSS grid columns,
// including the checkbox column we prepend, so it fills the full width.
<div style={{ gridColumn: '1 / -1' }}>
{logDataRows.length === 0 && !error && <RenderNoResultAlert />}
{error && <RenderErrorAlert />}
{logDataRows.length === 0 && !error && renderNoResultAlert()}
{error && renderErrorAlert()}
</div>
) : null,
}}
@@ -96,6 +96,8 @@ interface LogsDatePickerProps {
popoverContentProps?: typeof PopoverContent
hideWarnings?: boolean
align?: 'start' | 'end' | 'center'
open?: boolean
onOpenChange?: (open: boolean) => void
}
export const LogsDatePicker = ({
@@ -106,8 +108,16 @@ export const LogsDatePicker = ({
popoverContentProps,
hideWarnings,
align = 'end',
open: openProp,
onOpenChange,
}: PropsWithChildren<LogsDatePickerProps>) => {
const [open, setOpen] = useState(false)
const [internalOpen, setInternalOpen] = useState(false)
const isControlled = openProp !== undefined
const open = isControlled ? openProp : internalOpen
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next)
onOpenChange?.(next)
}
const [customValue, setCustomValue] = useState('')
const displayedHelpers = useMemo(() => {
@@ -4,6 +4,7 @@ import type { LogData } from './Logs.types'
import {
buildLogsPrompt,
extractEdgeFunctionName,
formatLogsAsCsv,
formatLogsAsJson,
formatLogsAsMarkdown,
} from './Logs.utils'
@@ -35,6 +36,72 @@ describe('Logs.utils', () => {
})
})
describe('formatLogsAsCsv', () => {
test('returns empty string for empty list', () => {
expect(formatLogsAsCsv([])).toBe('')
})
test('formats single row with header line', () => {
const rows: LogData[] = [createLog({ id: '1', event_message: 'hello' })]
const result = formatLogsAsCsv(rows)
const [header, row] = result.split('\r\n')
expect(header.split(',').sort()).toEqual(['event_message', 'id', 'timestamp'].sort())
expect(row).toContain('1')
expect(row).toContain('hello')
})
test('formats multiple rows', () => {
const rows: LogData[] = [
createLog({ id: '1', event_message: 'first' }),
createLog({ id: '2', event_message: 'second' }),
]
const lines = formatLogsAsCsv(rows).split('\r\n')
expect(lines).toHaveLength(3)
expect(lines[1]).toContain('first')
expect(lines[2]).toContain('second')
})
test('escapes commas, quotes, and newlines per RFC 4180', () => {
const rows: LogData[] = [
createLog({
id: 'a,b',
event_message: 'line1\nline2',
}),
createLog({
id: 'c"d',
event_message: 'has "quotes"',
}),
]
const result = formatLogsAsCsv(rows)
expect(result).toContain('"a,b"')
expect(result).toContain('"line1\nline2"')
expect(result).toContain('"c""d"')
expect(result).toContain('"has ""quotes"""')
})
test('emits columns based on the first row', () => {
const rows: LogData[] = [
{ id: '1', event_message: 'first', timestamp: 1 },
// Extra `status` key on later rows is dropped because headers
// come from the first row.
{ id: '2', event_message: 'second', timestamp: 2, status: '500' } as LogData,
]
const result = formatLogsAsCsv(rows)
expect(result).not.toContain('500')
expect(result.split('\r\n')[0]).toBe('id,event_message,timestamp')
})
test('renders null as the string "null" and undefined as empty', () => {
const rows: LogData[] = [
{ id: '1', event_message: null as unknown as string, timestamp: undefined as any },
]
const result = formatLogsAsCsv(rows)
const dataRow = result.split('\r\n')[1]
// Order matches first-row keys: id, event_message, timestamp
expect(dataRow).toBe('1,null,')
})
})
describe('formatLogsAsMarkdown', () => {
test('formats single log with timestamp', () => {
const rows: LogData[] = [
@@ -8,6 +8,7 @@ import logConstants from 'shared-data/log-constants'
import { LogsTableName, SQL_FILTER_TEMPLATES } from './Logs.constants'
import type { Filters, LogData, LogsEndpointParams, QueryType } from './Logs.types'
import { convertResultsToCSV } from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils'
import BackwardIterator from '@/components/ui/CodeEditor/Providers/BackwardIterator'
/**
@@ -745,6 +746,10 @@ export function formatLogsAsJson(rows: LogData[]): string {
return JSON.stringify(rows, null, 2)
}
export function formatLogsAsCsv(rows: LogData[]): string {
return convertResultsToCSV(rows as unknown as Record<string, unknown>[]) ?? ''
}
export function formatLogsAsMarkdown(rows: LogData[]): string {
return rows
.map((row, i) => {
@@ -2,7 +2,7 @@ import { useParams } from 'common'
import dayjs from 'dayjs'
import { Rewind } from 'lucide-react'
import { useRouter } from 'next/router'
import { PropsWithChildren, useEffect, useState } from 'react'
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
import { Button } from 'ui'
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
@@ -17,9 +17,11 @@ import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.
import { maybeShowUpgradePromptIfNotEntitled } from './Logs.utils'
import { LogTable } from './LogTable'
import UpgradePrompt from './UpgradePrompt'
import { useLogsPreviewShortcuts } from './useLogsPreviewShortcuts'
import PreviewFilterPanel from '@/components/interfaces/Settings/Logs/PreviewFilterPanel'
import LoadingOpacity from '@/components/ui/LoadingOpacity'
import ShimmerLine from '@/components/ui/ShimmerLine'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import useLogsPreview from '@/hooks/analytics/useLogsPreview'
import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
@@ -29,6 +31,7 @@ import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
/**
* Calculates the appropriate time range for bar click filtering based on the current time range duration.
@@ -112,6 +115,7 @@ export const LogsPreviewer = ({
const { data: organization } = useSelectedOrganizationQuery()
const state = useDatabaseSelectorStateSnapshot()
const searchInputRef = useRef<HTMLInputElement>(null)
const [showChart, setShowChart] = useState(true)
const [selectedDatePickerValue, setSelectedDatePickerValue] = useState<DatePickerValue>(
getDefaultDatePickerValue()
@@ -286,8 +290,22 @@ export const LogsPreviewer = ({
},
selectedDatePickerValue,
setSelectedDatePickerValue,
searchInputRef,
}
useLogsPreviewShortcuts({
searchInputRef,
hasSearch: search.length > 0,
onResetSearch: () => {
setSearch('')
setSelectedLogId(null)
},
onRefresh: handleRefresh,
onToggleChart: () => setShowChart((prev) => !prev),
onLoadOlder: loadOlder,
canLoadOlder: !error && logData.length > 0 && !isLoadingOlder,
})
return (
<div className="flex-1 flex flex-col h-full">
<PreviewFilterPanel {...filterPanelProps} />
@@ -350,15 +368,17 @@ export const LogsPreviewer = ({
</div>
{!error && logData.length > 0 && (
<div className="border-t flex flex-row items-center gap-3 p-2">
<Button
onClick={loadOlder}
icon={<Rewind />}
type="default"
loading={isLoadingOlder}
disabled={isLoadingOlder}
>
Load older
</Button>
<ShortcutTooltip shortcutId={SHORTCUT_IDS.LOGS_PREVIEW_LOAD_OLDER} side="top">
<Button
onClick={loadOlder}
icon={<Rewind />}
type="default"
loading={isLoadingOlder}
disabled={isLoadingOlder}
>
Load older
</Button>
</ShortcutTooltip>
<div className="text-sm text-foreground-lighter">
Showing <span className="font-mono">{logData.length}</span> results
</div>
@@ -11,14 +11,18 @@ import type { LogData, QueryType } from './Logs.types'
import { buildLogsPrompt } from './Logs.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
import { ShortcutBadge } from '@/components/ui/ShortcutBadge'
import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
export type LogCopyFormat = 'json' | 'markdown' | 'csv'
interface MultiSelectActionBarProps {
selectedRows: Set<string>
selectedRowsData: LogData[]
copiedFormat: 'json' | 'markdown' | null
onCopy: (format: 'json' | 'markdown') => void
copiedFormat: LogCopyFormat | null
onCopy: (format: LogCopyFormat) => void
onClear: () => void
queryType?: QueryType
sqlQuery?: string
@@ -65,14 +69,21 @@ export function MultiSelectActionBar({
{copiedFormat ? 'Copied!' : 'Copy'}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => onCopy('json')} className="gap-2 text-xs">
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem onClick={() => onCopy('json')} className="gap-x-2">
<Copy size={13} />
Copy as JSON
<p>Copy as JSON</p>
<ShortcutBadge shortcutId={SHORTCUT_IDS.RESULTS_COPY_JSON} className="ml-auto" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onCopy('markdown')} className="gap-2 text-xs">
<DropdownMenuItem onClick={() => onCopy('markdown')} className="gap-x-2">
<Copy size={13} />
Copy as Markdown
<p>Copy as Markdown</p>
<ShortcutBadge shortcutId={SHORTCUT_IDS.RESULTS_COPY_MARKDOWN} className="ml-auto" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onCopy('csv')} className="gap-x-2">
<Copy size={13} />
<p>Copy as CSV</p>
<ShortcutBadge shortcutId={SHORTCUT_IDS.RESULTS_COPY_CSV} className="ml-auto" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -2,7 +2,7 @@ import { useParams } from 'common'
import { Eye, EyeOff, RefreshCw, Search, Terminal, X } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useEffect, useState, type RefObject } from 'react'
import {
Button,
cn,
@@ -26,8 +26,12 @@ import type { Filters, LogSearchCallback, LogTemplate } from './Logs.types'
import LogsFilterPopover from './LogsFilterPopover'
import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query'
import { IS_PLATFORM } from '@/lib/constants'
import { onSearchInputEscape } from '@/lib/keyboard'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
interface PreviewFilterPanelProps {
defaultSearchValue?: string
@@ -52,6 +56,7 @@ interface PreviewFilterPanelProps {
className?: string
selectedDatePickerValue: DatePickerValue
setSelectedDatePickerValue: (value: DatePickerValue) => void
searchInputRef?: RefObject<HTMLInputElement | null>
}
/**
@@ -76,10 +81,16 @@ const PreviewFilterPanel = ({
className,
selectedDatePickerValue,
setSelectedDatePickerValue,
searchInputRef,
}: PreviewFilterPanelProps) => {
const router = useRouter()
const { ref } = useParams()
const [search, setSearch] = useState('')
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_DATE_PICKER, () =>
setIsDatePickerOpen((prev) => !prev)
)
const logName = router.pathname.split('/').pop()
@@ -118,16 +129,27 @@ const PreviewFilterPanel = ({
}}
>
<InputGroup className="w-60">
<InputGroupInput
size="tiny"
placeholder="Search events"
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
setSearch(e.target.value)
handleInputSearch(e.target.value)
}}
/>
<ShortcutTooltip
shortcutId={SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH}
label="Search logs"
side="bottom"
>
<InputGroupInput
ref={searchInputRef}
size="tiny"
placeholder="Search events"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={onSearchInputEscape(search, (next) => {
setSearch(next)
handleInputSearch(next)
})}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => {
setSearch(e.target.value)
handleInputSearch(e.target.value)
}}
/>
</ShortcutTooltip>
<InputGroupAddon>
<Search />
</InputGroupAddon>
@@ -165,34 +187,29 @@ const PreviewFilterPanel = ({
</InputGroup>
</form>
<Tooltip>
<TooltipTrigger asChild>
<Button
title="refresh"
type="default"
className="px-1.5"
icon={
<div className="relative">
{newCount > 0 && (
<div className="absolute -top-3 -right-3 flex items-center justify-center">
<div className="absolute h-4 w-4 animate-ping rounded-full bg-brand opacity-60"></div>
<div className="relative z-10 flex h-4 min-w-4 items-center justify-center rounded-full bg-brand-500 px-1 text-[10px] font-medium leading-none text-white">
{newCount > 1000 ? `${Math.floor(newCount / 100) / 10}K` : newCount}
</div>
<ShortcutTooltip shortcutId={SHORTCUT_IDS.LOGS_PREVIEW_REFRESH} side="bottom">
<Button
title="refresh"
type="default"
className="px-1.5"
icon={
<div className="relative">
{newCount > 0 && (
<div className="absolute -top-3 -right-3 flex items-center justify-center">
<div className="absolute h-4 w-4 animate-ping rounded-full bg-brand opacity-60"></div>
<div className="relative z-10 flex h-4 min-w-4 items-center justify-center rounded-full bg-brand-500 px-1 text-[10px] font-medium leading-none text-white">
{newCount > 1000 ? `${Math.floor(newCount / 100) / 10}K` : newCount}
</div>
)}
<RefreshCw />
</div>
}
loading={isLoading}
disabled={isLoading}
onClick={onRefresh}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Refresh logs
</TooltipContent>
</Tooltip>
</div>
)}
<RefreshCw />
</div>
}
loading={isLoading}
disabled={isLoading}
onClick={onRefresh}
/>
</ShortcutTooltip>
<LogsDatePicker
helpers={PREVIEWER_DATEPICKER_HELPERS}
@@ -201,6 +218,8 @@ const PreviewFilterPanel = ({
setSelectedDatePickerValue(vals)
}}
value={selectedDatePickerValue}
open={isDatePickerOpen}
onOpenChange={setIsDatePickerOpen}
/>
{FILTER_OPTIONS[table] !== undefined && (
@@ -236,13 +255,15 @@ const PreviewFilterPanel = ({
</div>
)}
<div className="flex items-center space-x-2">
<Button
type="default"
onClick={() => onToggleEventChart()}
icon={isShowingEventChart ? <Eye /> : <EyeOff />}
>
Chart
</Button>
<ShortcutTooltip shortcutId={SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_CHART} side="bottom">
<Button
type="default"
onClick={() => onToggleEventChart()}
icon={isShowingEventChart ? <Eye /> : <EyeOff />}
>
Chart
</Button>
</ShortcutTooltip>
</div>
{Boolean(csvData) && (
<DownloadResultsButton
@@ -0,0 +1,52 @@
import type { RefObject } from 'react'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
interface UseLogsPreviewShortcutsParams {
searchInputRef: RefObject<HTMLInputElement | null>
hasSearch: boolean
onResetSearch: () => void
onRefresh: () => void
onToggleChart: () => void
onLoadOlder: () => void
canLoadOlder: boolean
}
/**
* Toolbar-level shortcuts for the LogsPreviewer (search focus, reset filters,
* refresh, toggle histogram, load older). Grid-level shortcuts (selection,
* arrow navigation, escape) live alongside the grid in LogTable.
*
* Mounted once inside LogsPreviewer so the shortcuts auto-activate on every
* consumer of the component (function logs, function invocations, logs
* explorer).
*/
export function useLogsPreviewShortcuts({
searchInputRef,
hasSearch,
onResetSearch,
onRefresh,
onToggleChart,
onLoadOlder,
canLoadOlder,
}: UseLogsPreviewShortcutsParams) {
useShortcut(
SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
() => {
searchInputRef.current?.focus()
searchInputRef.current?.select()
},
{ label: 'Search logs' }
)
useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, onResetSearch, {
enabled: hasSearch,
})
useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_REFRESH, onRefresh)
useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_CHART, onToggleChart)
useShortcut(SHORTCUT_IDS.LOGS_PREVIEW_LOAD_OLDER, onLoadOlder, { enabled: canLoadOlder })
}
@@ -48,6 +48,7 @@ const ACTIVE_SURFACE_SHORTCUT_IDS = [
SHORTCUT_IDS.FUNCTION_DETAIL_OPEN_TEST,
SHORTCUT_IDS.FUNCTION_OVERVIEW_INTERVAL_15MIN,
SHORTCUT_IDS.FUNCTIONS_LIST_REFRESH,
SHORTCUT_IDS.LOGS_PREVIEW_REFRESH,
SHORTCUT_IDS.SQL_EDITOR_FORMAT,
SHORTCUT_IDS.STORAGE_BUCKETS_REFRESH,
SHORTCUT_IDS.STORAGE_EXPLORER_REFRESH,
@@ -174,6 +175,7 @@ describe('ShortcutsReferenceSheet', () => {
expect(screen.getByText('Edge Function Actions')).toBeInTheDocument()
expect(screen.getByText('Edge Function Overview')).toBeInTheDocument()
expect(screen.getByText('Edge Functions')).toBeInTheDocument()
expect(screen.getByText('Logs Explorer')).toBeInTheDocument()
expect(screen.getByText('SQL Editor')).toBeInTheDocument()
expect(screen.getByText('Storage Buckets')).toBeInTheDocument()
expect(screen.getByText('Storage File Explorer')).toBeInTheDocument()
@@ -181,6 +183,7 @@ describe('ShortcutsReferenceSheet', () => {
expect(screen.queryByText('functions-detail')).not.toBeInTheDocument()
expect(screen.queryByText('functions-list')).not.toBeInTheDocument()
expect(screen.queryByText('functions-overview')).not.toBeInTheDocument()
expect(screen.queryByText('logs-preview')).not.toBeInTheDocument()
expect(screen.queryByText('sql-editor')).not.toBeInTheDocument()
expect(screen.queryByText('storage-buckets')).not.toBeInTheDocument()
expect(screen.queryByText('storage-explorer')).not.toBeInTheDocument()
@@ -51,6 +51,7 @@ const GROUP_LABELS: Record<string, string> = {
'functions-overview': 'Edge Function Overview',
'inline-editor': 'Inline Editor',
'list-page': 'List pages',
'logs-preview': 'Logs Explorer',
nav: 'Navigation',
'operation-queue': 'Operation Queue',
results: 'Results',
@@ -31,6 +31,7 @@ export const SHORTCUT_REFERENCE_GROUP_ORDER = [
SHORTCUT_REFERENCE_GROUPS.NAVIGATION_FUNCTION_DETAIL,
'functions-detail',
'functions-overview',
'logs-preview',
'nav',
'ai-assistant',
'inline-editor',
+7
View File
@@ -14,6 +14,7 @@ import {
functionsOverviewRegistry,
} from './registry/functions-overview'
import { LIST_PAGE_SHORTCUT_IDS, listPageRegistry } from './registry/list-page'
import { LOGS_PREVIEW_SHORTCUT_IDS, logsPreviewRegistry } from './registry/logs-preview'
import {
SCHEMA_VISUALIZER_SHORTCUT_IDS,
schemaVisualizerRegistry,
@@ -108,6 +109,9 @@ export const SHORTCUT_IDS = {
...FUNCTIONS_DETAIL_NAV_SHORTCUT_IDS,
// Per-function Overview tab shortcuts (intervals, refresh, open logs)
...FUNCTIONS_OVERVIEW_SHORTCUT_IDS,
// LogsPreviewer shortcuts (Function Logs, Function Invocations, Logs Explorer)
...LOGS_PREVIEW_SHORTCUT_IDS,
} as const
/**
@@ -410,4 +414,7 @@ export const SHORTCUT_DEFINITIONS: Record<ShortcutId, ShortcutDefinition> = {
...functionsDetailNavRegistry,
// Per-function Overview tab shortcut registration
...functionsOverviewRegistry,
// LogsPreviewer shortcut registration
...logsPreviewRegistry,
}
@@ -0,0 +1,99 @@
import { RegistryDefinations } from '../types'
/**
* Shortcuts scoped to the LogsPreviewer component — used by Function Logs,
* Function Invocations, and the Logs Explorer page. They are page-scoped:
* each shortcut is only active while LogsPreviewer is mounted.
*
* Grid-related bindings (start-nav, toggle-all, toggle-row, escape) mirror the
* Auth Users / Table Editor patterns so the keyboard model stays consistent
* across our react-data-grid surfaces.
*/
export const LOGS_PREVIEW_SHORTCUT_IDS = {
LOGS_PREVIEW_REFRESH: 'logs-preview.refresh',
LOGS_PREVIEW_TOGGLE_CHART: 'logs-preview.toggle-chart',
LOGS_PREVIEW_LOAD_OLDER: 'logs-preview.load-older',
LOGS_PREVIEW_TOGGLE_DATE_PICKER: 'logs-preview.toggle-date-picker',
LOGS_PREVIEW_TOGGLE_ALL_SELECTION: 'logs-preview.toggle-all-selection',
LOGS_PREVIEW_TOGGLE_ROW_SELECTION: 'logs-preview.toggle-row-selection',
LOGS_PREVIEW_EXIT_SELECTION: 'logs-preview.exit-selection',
LOGS_PREVIEW_CLOSE_PANEL: 'logs-preview.close-panel',
LOGS_PREVIEW_START_NAV_DOWN: 'logs-preview.start-nav-down',
LOGS_PREVIEW_START_NAV_UP: 'logs-preview.start-nav-up',
}
export type LogsPreviewShortcutId =
(typeof LOGS_PREVIEW_SHORTCUT_IDS)[keyof typeof LOGS_PREVIEW_SHORTCUT_IDS]
export const logsPreviewRegistry: RegistryDefinations<LogsPreviewShortcutId> = {
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_REFRESH]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_REFRESH,
label: 'Refresh logs',
sequence: ['Shift+R'],
showInSettings: false,
options: { ignoreInputs: true, registerInCommandMenu: true },
},
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_CHART]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_CHART,
label: 'Toggle histogram',
sequence: ['Shift+H'],
showInSettings: false,
options: { ignoreInputs: true, registerInCommandMenu: true },
},
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_LOAD_OLDER]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_LOAD_OLDER,
label: 'Load older logs',
sequence: ['Shift+L'],
showInSettings: false,
options: { ignoreInputs: true, registerInCommandMenu: true },
},
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_DATE_PICKER]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_DATE_PICKER,
label: 'Open time range picker',
sequence: ['Shift+P'],
showInSettings: false,
options: { ignoreInputs: true, registerInCommandMenu: true },
},
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ALL_SELECTION]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ALL_SELECTION,
label: 'Toggle selection on all displayed logs',
sequence: ['Mod+A'],
showInSettings: false,
options: { ignoreInputs: true },
},
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ROW_SELECTION]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_TOGGLE_ROW_SELECTION,
label: 'Toggle selection on current row',
sequence: ['Shift+Space'],
showInSettings: false,
options: { ignoreInputs: true },
},
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_EXIT_SELECTION]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_EXIT_SELECTION,
label: 'Clear log selection',
sequence: ['Escape'],
showInSettings: false,
options: { ignoreInputs: true, conflictBehavior: 'allow' },
},
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_CLOSE_PANEL]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_CLOSE_PANEL,
label: 'Close log details panel',
sequence: ['Escape'],
showInSettings: false,
options: { ignoreInputs: true, conflictBehavior: 'allow' },
},
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_DOWN]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_DOWN,
label: 'Move focus into logs grid',
sequence: ['ArrowDown'],
showInSettings: false,
options: { ignoreInputs: true },
},
[LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_UP]: {
id: LOGS_PREVIEW_SHORTCUT_IDS.LOGS_PREVIEW_START_NAV_UP,
label: 'Move focus into logs grid',
sequence: ['ArrowUp'],
showInSettings: false,
options: { ignoreInputs: true },
},
}