mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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 --> [](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:
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',
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user