diff --git a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx index 3f889de125b..ffc9bcb7bd6 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -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(null) const [selectedRows, setSelectedRows] = useState>(new Set()) - const [copiedFormat, setCopiedFormat] = useState<'json' | 'markdown' | null>(null) + const [copiedFormat, setCopiedFormat] = useState(null) const triggerRef = useRef(null) const [activeRow, setActiveRow] = useState(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 = (
) - 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 + return } if (!data) return null return (
- {!queryType && } + {!queryType && logsExplorerTableHeader} { 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.
- {logDataRows.length === 0 && !error && } - {error && } + {logDataRows.length === 0 && !error && renderNoResultAlert()} + {error && renderErrorAlert()}
) : null, }} diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index c0c2226ca9a..1fed416743e 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -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) => { - 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(() => { diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts index f52ec3c8a5d..8023a487d1f 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.test.ts @@ -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[] = [ diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index c89e2e6dcfe..5bcd418f0e0 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -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[]) ?? '' +} + export function formatLogsAsMarkdown(rows: LogData[]): string { return rows .map((row, i) => { diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index 6a01467cf5a..f56ff958199 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -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(null) const [showChart, setShowChart] = useState(true) const [selectedDatePickerValue, setSelectedDatePickerValue] = useState( 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 (
@@ -350,15 +368,17 @@ export const LogsPreviewer = ({
{!error && logData.length > 0 && (
- + + +
Showing {logData.length} results
diff --git a/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx b/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx index cc7dbd50a9f..cd0522c3df2 100644 --- a/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/MultiSelectActionBar.tsx @@ -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 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'} - - onCopy('json')} className="gap-2 text-xs"> + + onCopy('json')} className="gap-x-2"> - Copy as JSON +

Copy as JSON

+
- onCopy('markdown')} className="gap-2 text-xs"> + onCopy('markdown')} className="gap-x-2"> - Copy as Markdown +

Copy as Markdown

+ +
+ onCopy('csv')} className="gap-x-2"> + +

Copy as CSV

+
diff --git a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx index f955d82df01..408af59777c 100644 --- a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx @@ -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 } /** @@ -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 = ({ }} > - setSearch(e.target.value)} - onBlur={(e: React.FocusEvent) => { - setSearch(e.target.value) - handleInputSearch(e.target.value) - }} - /> + + setSearch(e.target.value)} + onKeyDown={onSearchInputEscape(search, (next) => { + setSearch(next) + handleInputSearch(next) + })} + onBlur={(e: React.FocusEvent) => { + setSearch(e.target.value) + handleInputSearch(e.target.value) + }} + /> + @@ -165,34 +187,29 @@ const PreviewFilterPanel = ({ - - -
+ )} + +
+ } + loading={isLoading} + disabled={isLoading} + onClick={onRefresh} + /> + {FILTER_OPTIONS[table] !== undefined && ( @@ -236,13 +255,15 @@ const PreviewFilterPanel = ({ )}
- + + +
{Boolean(csvData) && ( + 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 }) +} diff --git a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx index 5a4f95a4f65..2b42db09204 100644 --- a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx +++ b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx @@ -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() diff --git a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx index 2183923716e..76b7451a789 100644 --- a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx +++ b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx @@ -51,6 +51,7 @@ const GROUP_LABELS: Record = { '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', diff --git a/apps/studio/state/shortcuts/referenceGroups.ts b/apps/studio/state/shortcuts/referenceGroups.ts index df3187e4681..545cea9c947 100644 --- a/apps/studio/state/shortcuts/referenceGroups.ts +++ b/apps/studio/state/shortcuts/referenceGroups.ts @@ -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', diff --git a/apps/studio/state/shortcuts/registry.ts b/apps/studio/state/shortcuts/registry.ts index bc2590ef93f..26366888cea 100644 --- a/apps/studio/state/shortcuts/registry.ts +++ b/apps/studio/state/shortcuts/registry.ts @@ -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 = { ...functionsDetailNavRegistry, // Per-function Overview tab shortcut registration ...functionsOverviewRegistry, + + // LogsPreviewer shortcut registration + ...logsPreviewRegistry, } diff --git a/apps/studio/state/shortcuts/registry/logs-preview.ts b/apps/studio/state/shortcuts/registry/logs-preview.ts new file mode 100644 index 00000000000..bd35305ecaa --- /dev/null +++ b/apps/studio/state/shortcuts/registry/logs-preview.ts @@ -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 = { + [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 }, + }, +}