diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index f150c99d8bd..1f4200f72fd 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -3,16 +3,24 @@ import { compact } from 'lodash' import { useSearchParams } from 'next/navigation' import { parseAsNativeArrayOf, parseAsString, useQueryStates } from 'nuqs' import { useEffect, useMemo } from 'react' -import { CalculatedColumn, CellKeyboardEvent } from 'react-data-grid' +import { + CalculatedColumn, + CellKeyboardEvent, + CellKeyDownArgs, + RowsChangeData, +} from 'react-data-grid' import { copyToClipboard } from 'ui' import { FilterOperatorOptions } from './components/header/filter/Filter.constants' import { STORAGE_KEY_PREFIX } from './constants' -import type { Sort, SupaColumn, SupaTable } from './types' +import type { Sort, SupaColumn, SupaRow, SupaTable } from './types' import { formatClipboardValue } from './utils/common' +import { isBoolColumn } from './utils/types' import type { Filter, SavedState } from '@/components/grid/types' import { Entity, isTableLike } from '@/data/table-editor/table-editor-types' import { BASE_PATH } from '@/lib/constants' +import { eventMatchesAnyShortcut } from '@/state/shortcuts/matchEvent' +import { tableEditorRegistry } from '@/state/shortcuts/registry/table-editor' export function formatSortURLParams(tableName: string, sort?: string[]): Sort[] { if (Array.isArray(sort)) { @@ -264,18 +272,51 @@ export function useSyncTableEditorStateFromLocalStorageWithUrl({ }, [urlParams, table, projectRef]) } -export const handleCopyCell = ( - { - mode, - column, - row, - }: { mode: 'SELECT' | 'EDIT'; column: CalculatedColumn; row: any }, - event: CellKeyboardEvent +export const handleCellKeyDown = ( + args: CellKeyDownArgs, + event: CellKeyboardEvent, + context?: { + rows: TRow[] + columns: SupaColumn[] + onRowsChange: (rows: TRow[], data: RowsChangeData) => void + } ) => { - if (mode === 'SELECT' && event.code === 'KeyC' && (event.metaKey || event.ctrlKey)) { - const colKey = column.key - const cellValue = row[colKey] ?? '' + const { mode, column, row, rowIdx } = args + if (mode !== 'SELECT') return + + if (event.code === 'KeyC' && (event.metaKey || event.ctrlKey)) { + const cellValue = row[column.key] ?? '' const value = formatClipboardValue(cellValue) copyToClipboard(value) } + // Let registered shortcuts win over rdg's "type a key to enter edit mode" default. + if (eventMatchesAnyShortcut(event.nativeEvent, tableEditorRegistry)) { + event.preventGridDefault() + return + } + + // Toggle boolean cells with T/F when no modifier keys are pressed. + if (context === undefined) return + + const key = event.key.toLowerCase() + if (event.altKey || event.ctrlKey || event.metaKey || (key !== 't' && key !== 'f')) return + + const supaColumn = context.columns.find((c) => c.name === column.key) + if ( + supaColumn === undefined || + !isBoolColumn(supaColumn.dataType) || + column.renderEditCell == null + ) { + return + } + + event.preventDefault() + event.preventGridDefault() + + const nextValue = key === 't' + if (row[column.key] === nextValue) return + + const updatedRows = [...context.rows] + updatedRows[rowIdx] = { ...row, [column.key]: nextValue } + context.onRowsChange(updatedRows, { indexes: [rowIdx], column }) } diff --git a/apps/studio/components/grid/components/common/Shortcuts.tsx b/apps/studio/components/grid/components/common/Shortcuts.tsx index 8e1f7dca488..0c5d6a26b8c 100644 --- a/apps/studio/components/grid/components/common/Shortcuts.tsx +++ b/apps/studio/components/grid/components/common/Shortcuts.tsx @@ -1,10 +1,13 @@ -import { RefObject } from 'react' +import { RefObject, useContext } from 'react' import type { DataGridHandle } from 'react-data-grid' import { SupaRow } from '@/components/grid/types' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useShortcut } from '@/state/shortcuts/useShortcut' -import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' +import { + TableEditorTableStateContext, + useTableEditorTableStateSnapshot, +} from '@/state/table-editor-table' type ShortcutsProps = { gridRef: RefObject @@ -13,6 +16,16 @@ type ShortcutsProps = { export function Shortcuts({ gridRef, rows }: ShortcutsProps) { const snap = useTableEditorTableStateSnapshot() + const state = useContext(TableEditorTableStateContext) + const canStartNavigation = !snap.selectedCellPosition && rows.length > 0 + + const startGridNavigation = () => { + const frozenColumns = snap.gridColumns.filter((x) => x.frozen) + gridRef.current?.selectCell({ + idx: frozenColumns.length, + rowIdx: 0, + }) + } useShortcut(SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW, () => { if (snap.selectedCellPosition) { @@ -51,5 +64,44 @@ export function Shortcuts({ gridRef, rows }: ShortcutsProps) { }) }) + useShortcut( + SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ROW_SELECTION, + () => { + const rowIdx = state.selectedCellPosition?.rowIdx + if (rowIdx === undefined) return + + const row = rows[rowIdx] + if (!row) return + + const next = new Set(state.selectedRows) + if (next.has(row.idx)) next.delete(row.idx) + else next.add(row.idx) + + state.setSelectedRows(next) + }, + { + enabled: !!snap.selectedCellPosition, + } + ) + + useShortcut(SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_DOWN, startGridNavigation, { + enabled: canStartNavigation, + }) + + useShortcut(SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_UP, startGridNavigation, { + enabled: canStartNavigation, + }) + + useShortcut( + SHORTCUT_IDS.TABLE_EDITOR_EXIT_SELECTION, + () => { + snap.setSelectedCellPosition(null) + ;(document.activeElement as HTMLElement | null)?.blur() + }, + { + enabled: !!snap.selectedCellPosition, + } + ) + return null } diff --git a/apps/studio/components/grid/components/grid/Grid.tsx b/apps/studio/components/grid/components/grid/Grid.tsx index 5b57e2ba9b9..4377e34d5ef 100644 --- a/apps/studio/components/grid/components/grid/Grid.tsx +++ b/apps/studio/components/grid/components/grid/Grid.tsx @@ -14,7 +14,7 @@ import { useOnRowsChange } from './Grid.utils' import { GridError } from './GridError' import { RowContextMenuProvider, RowRenderer } from './RowRenderer' import { useTableFilter } from '@/components/grid/hooks/useTableFilter' -import { handleCopyCell } from '@/components/grid/SupabaseGrid.utils' +import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils' import { formatForeignKeys } from '@/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils' import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-constraints-query' import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants' @@ -362,7 +362,13 @@ export const Grid = memo( onRowDoubleClick(props.row, { name: props.column.name }) } }} - onCellKeyDown={handleCopyCell} + onCellKeyDown={(args, event) => + handleCellKeyDown(args, event, { + rows: rows ?? [], + columns: snap.table.columns, + onRowsChange, + }) + } /> {/* The DragOverlay is necessary to avoid styling issues while dragging a column */} diff --git a/apps/studio/components/grid/components/grid/SelectColumn.tsx b/apps/studio/components/grid/components/grid/SelectColumn.tsx index 8596cc65772..77ca3527b95 100644 --- a/apps/studio/components/grid/components/grid/SelectColumn.tsx +++ b/apps/studio/components/grid/components/grid/SelectColumn.tsx @@ -12,6 +12,9 @@ import { import { SELECT_COLUMN_KEY } from '../../constants' import type { SupaRow } from '../../types' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { Shortcut } from '@/components/ui/Shortcut' +import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useTableEditorStateSnapshot } from '@/state/table-editor' import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' @@ -125,17 +128,23 @@ function SelectCellFormatter({ return (
- + + + {row && ( - + onChange(!value, false)} + options={{ + registerInCommandMenu: true, + }} + > + +
) } diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index 9929ac61f8e..648ec60ded5 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -30,6 +30,7 @@ import { useExportAllRowsAsSql, } from '@/components/layouts/TableEditorLayout/ExportAllRows' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { Shortcut } from '@/components/ui/Shortcut' import { useTableRowsCountQuery } from '@/data/table-rows/table-rows-count-query' import { useTableRowsQuery } from '@/data/table-rows/table-rows-query' import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' @@ -41,6 +42,7 @@ import { useRoleImpersonationStateSnapshot, useSubscribeToImpersonatedRole, } from '@/state/role-impersonation-state' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' import { useTableEditorStateSnapshot } from '@/state/table-editor' import { useTableEditorTableStateSnapshot } from '@/state/table-editor-table' @@ -262,6 +264,14 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { snap.setSelectedRows(new Set(allRows.map((row) => row.idx)), true) } + const onToggleSelectAllInTable = () => { + if (snap.allRowsSelected) { + snap.setSelectedRows(new Set(), false) + } else { + onSelectAllRows() + } + } + const onRowsDelete = () => { const rowIdxs = Array.from(snap.selectedRows) as number[] const rows = allRows.filter((x) => rowIdxs.includes(x.idx)) @@ -386,28 +396,37 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { <>
{snap.editable && ( - } - onClick={onRowsDelete} - disabled={snap.allRowsSelected && isImpersonatingRole} - tooltip={{ - content: { - side: 'bottom', - text: - snap.allRowsSelected && isImpersonatingRole - ? 'Table truncation is not supported when impersonating a role' - : undefined, - }, + - {snap.allRowsSelected - ? `Delete all rows in table` - : snap.selectedRows.size > 1 - ? `Delete ${snap.selectedRows.size} rows` - : `Delete ${snap.selectedRows.size} row`} - + } + onClick={onRowsDelete} + disabled={snap.allRowsSelected && isImpersonatingRole} + tooltip={{ + content: { + side: 'bottom', + text: + snap.allRowsSelected && isImpersonatingRole + ? 'Table truncation is not supported when impersonating a role' + : undefined, + }, + }} + > + {snap.allRowsSelected + ? `Delete all rows in table` + : snap.selectedRows.size > 1 + ? `Delete ${snap.selectedRows.size} rows` + : `Delete ${snap.selectedRows.size} row`} + + )} {!snap.allRowsSelected ? ( @@ -473,14 +492,20 @@ const RowHeader = ({ tableQueriesEnabled = true }: RowHeaderProps) => { - {!snap.allRowsSelected && totalRows > allRows.length && ( + {snap.selectedRows.size > 0 && totalRows > allRows.length && ( <>
- + + + )}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx index 490de8e2971..5eeed6ff05c 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx @@ -12,7 +12,7 @@ import { import { CellDetailPanel } from './CellDetailPanel' import { formatCellValue, formatClipboardValue } from './Results.utils' -import { handleCopyCell } from '@/components/grid/SupabaseGrid.utils' +import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils' export const Results = ({ rows }: { rows: readonly any[] }) => { const [expandCell, setExpandCell] = useState(false) @@ -137,7 +137,7 @@ export const Results = ({ rows }: { rows: readonly any[] }) => { className="grow min-h-0 border-t-0" rowClass={() => '[&>.rdg-cell]:items-center'} onSelectedCellChange={setCellPosition} - onCellKeyDown={handleCopyCell} + onCellKeyDown={handleCellKeyDown} /> { const id = _id ? Number(_id) : undefined const snap = useTableEditorStateSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const isMobile = useBreakpoint() const [searchText, setSearchText] = useState('') const [tableToExport, setTableToExport] = useState() @@ -201,7 +199,6 @@ export const TableEditorMenu = () => {
{ + const actual = + await vi.importActual('@tanstack/react-hotkeys') + return { + ...actual, + getSequenceManager: vi.fn(), + } +}) + +type FakeRegistration = { + options: { enabled?: boolean } + sequence: string[] +} + +/** Swap the SequenceManager's registrations for the ones we care about. */ +function withRegistrations(registrations: FakeRegistration[]) { + const state = new Map(registrations.map((r, i) => [String(i), r])) + vi.mocked(getSequenceManager).mockReturnValue({ + registrations: { state }, + } as unknown as ReturnType) +} + +const shiftX = new KeyboardEvent('keydown', { + key: 'X', + code: 'KeyX', + shiftKey: true, +}) + +const arrowDown = new KeyboardEvent('keydown', { + key: 'ArrowDown', + code: 'ArrowDown', +}) + +const g = new KeyboardEvent('keydown', { + key: 'g', + code: 'KeyG', +}) + +const shiftK = new KeyboardEvent('keydown', { + key: 'K', + code: 'KeyK', + shiftKey: true, +}) + +const registry: RegistryDefinations = { + 'table.shift-x': { + id: 'table.shift-x', + label: 'Toggle row', + sequence: ['Shift+X'], + }, + 'table.arrow-down': { + id: 'table.arrow-down', + label: 'Start navigation (down)', + sequence: ['ArrowDown'], + }, + 'table.chord': { + id: 'table.chord', + label: 'Chord', + sequence: ['G', 'T'], + }, +} + +describe('eventMatchesAnyShortcut', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns false when there are no active registrations', () => { + withRegistrations([]) + expect(eventMatchesAnyShortcut(shiftX, registry)).toBe(false) + }) + + it('returns true when an active, enabled shortcut in scope matches', () => { + withRegistrations([{ options: {}, sequence: ['Shift+X'] }]) + expect(eventMatchesAnyShortcut(shiftX, registry)).toBe(true) + }) + + it('returns false when the matching shortcut is disabled', () => { + withRegistrations([{ options: { enabled: false }, sequence: ['ArrowDown'] }]) + expect(eventMatchesAnyShortcut(arrowDown, registry)).toBe(false) + }) + + it('treats enabled: undefined and enabled: true as enabled', () => { + withRegistrations([ + { options: {}, sequence: ['Shift+X'] }, + { options: { enabled: true }, sequence: ['ArrowDown'] }, + ]) + expect(eventMatchesAnyShortcut(arrowDown, registry)).toBe(true) + }) + + it('returns false when the matching registration is not in the target registry', () => { + // 'Shift+K' is registered and matches the event, but our scoped registry + // doesn't include it — so we should not claim a match. + withRegistrations([{ options: {}, sequence: ['Shift+K'] }]) + expect(eventMatchesAnyShortcut(shiftK, registry)).toBe(false) + }) + + it('matches any individual step of a chord sequence', () => { + withRegistrations([{ options: {}, sequence: ['G', 'T'] }]) + expect(eventMatchesAnyShortcut(g, registry)).toBe(true) + }) + + it('returns true if any enabled duplicate matches, even when a disabled one is also present', () => { + withRegistrations([ + { options: { enabled: false }, sequence: ['Shift+X'] }, + { options: {}, sequence: ['Shift+X'] }, + ]) + expect(eventMatchesAnyShortcut(shiftX, registry)).toBe(true) + }) + + it('returns false when no active sequence matches the event', () => { + withRegistrations([{ options: {}, sequence: ['Shift+X'] }]) + expect(eventMatchesAnyShortcut(arrowDown, registry)).toBe(false) + }) +}) diff --git a/apps/studio/state/shortcuts/matchEvent.ts b/apps/studio/state/shortcuts/matchEvent.ts new file mode 100644 index 00000000000..cf25515fcad --- /dev/null +++ b/apps/studio/state/shortcuts/matchEvent.ts @@ -0,0 +1,38 @@ +import { getSequenceManager, matchesKeyboardEvent } from '@tanstack/react-hotkeys' + +import { SHORTCUT_DEFINITIONS } from './registry' +import type { RegistryDefinations } from './types' + +/** + * Returns true if the given keyboard event matches a shortcut that is both: + * + * 1. **In the target registry** (defaults to every known shortcut, but callers + * can pass a subset like `tableEditorRegistry` to scope the check) + * 2. **Currently active and enabled** — i.e. a `useShortcut` is mounted for it + * AND its `enabled` option is not `false` + * + * Chord sequences (e.g. `['G', 'T']`) match on any individual step, so + * pressing `G` counts as a match while the chord is in flight. + * + * Respecting the live `enabled` state matters: if a shortcut is registered but + * gated off (e.g. `enabled: !!snap.selectedCellPosition`), we must NOT suppress + * the default behavior on its behalf, because the shortcut won't actually fire. + */ + +export function eventMatchesAnyShortcut( + event: KeyboardEvent, + registry: RegistryDefinations = SHORTCUT_DEFINITIONS +): boolean { + const scopedSteps = new Set(Object.values(registry).flatMap((def) => def.sequence)) + const activeRegistrations = getSequenceManager().registrations.state.values() + + for (const view of activeRegistrations) { + if (view.options.enabled === false) continue + const matches = view.sequence.some( + (step) => scopedSteps.has(step) && matchesKeyboardEvent(event, step) + ) + if (matches) return true + } + + return false +} diff --git a/apps/studio/state/shortcuts/registry.ts b/apps/studio/state/shortcuts/registry.ts index 73f3b687a0d..1c6e1c66e1f 100644 --- a/apps/studio/state/shortcuts/registry.ts +++ b/apps/studio/state/shortcuts/registry.ts @@ -1,3 +1,4 @@ +import { TABLE_EDITOR_SHORTCUT_IDS, tableEditorRegistry } from './registry/table-editor' import { ShortcutDefinition } from './types' /** @@ -16,10 +17,6 @@ export const SHORTCUT_IDS = { RESULTS_COPY_JSON: 'results.copy-json', RESULTS_COPY_CSV: 'results.copy-csv', RESULTS_DOWNLOAD_CSV: 'results.download-csv', - TABLE_EDITOR_JUMP_FIRST_ROW: 'table-editor.jump-first-row', - TABLE_EDITOR_JUMP_LAST_ROW: 'table-editor.jump-last-row', - TABLE_EDITOR_JUMP_FIRST_COL: 'table-editor.jump-first-col', - TABLE_EDITOR_JUMP_LAST_COL: 'table-editor.jump-last-col', DATA_TABLE_TOGGLE_FILTERS: 'data-table.toggle-filters', DATA_TABLE_RESET_FILTERS: 'data-table.reset-filters', DATA_TABLE_RESET_COLUMNS: 'data-table.reset-columns', @@ -49,6 +46,9 @@ export const SHORTCUT_IDS = { NAV_ORG_BILLING: 'nav.org-billing', NAV_ORG_SETTINGS: 'nav.org-settings', SHORTCUTS_OPEN_REFERENCE: 'shortcuts.open-reference', + + // Table editor shortcuts + ...TABLE_EDITOR_SHORTCUT_IDS, } as const /** @@ -113,34 +113,6 @@ export const SHORTCUT_DEFINITIONS: Record = { label: 'Download results as CSV', sequence: ['Mod+Shift+D'], }, - [SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW]: { - id: SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW, - label: 'Jump to first row', - sequence: ['Mod+ArrowUp'], - showInSettings: false, - options: { ignoreInputs: true }, - }, - [SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_ROW]: { - id: SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_ROW, - label: 'Jump to last row', - sequence: ['Mod+ArrowDown'], - showInSettings: false, - options: { ignoreInputs: true }, - }, - [SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_COL]: { - id: SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_COL, - label: 'Jump to first column', - sequence: ['Mod+ArrowLeft'], - showInSettings: false, - options: { ignoreInputs: true }, - }, - [SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_COL]: { - id: SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_COL, - label: 'Jump to last column', - sequence: ['Mod+ArrowRight'], - showInSettings: false, - options: { ignoreInputs: true }, - }, [SHORTCUT_IDS.AI_ASSISTANT_CANCEL_EDIT]: { id: SHORTCUT_IDS.AI_ASSISTANT_CANCEL_EDIT, label: 'Cancel AI Assistant edit', @@ -322,4 +294,7 @@ export const SHORTCUT_DEFINITIONS: Record = { showInSettings: false, options: { ignoreInputs: true }, }, + + // Table editor shortcut registration + ...tableEditorRegistry, } diff --git a/apps/studio/state/shortcuts/registry/table-editor.ts b/apps/studio/state/shortcuts/registry/table-editor.ts new file mode 100644 index 00000000000..5981d3a4d42 --- /dev/null +++ b/apps/studio/state/shortcuts/registry/table-editor.ts @@ -0,0 +1,98 @@ +import { RegistryDefinations } from '../types' + +export const TABLE_EDITOR_SHORTCUT_IDS = { + TABLE_EDITOR_JUMP_FIRST_ROW: 'table-editor.jump-first-row', + TABLE_EDITOR_JUMP_LAST_ROW: 'table-editor.jump-last-row', + TABLE_EDITOR_JUMP_FIRST_COL: 'table-editor.jump-first-col', + TABLE_EDITOR_JUMP_LAST_COL: 'table-editor.jump-last-col', + TABLE_EDITOR_TOGGLE_ROW_SELECTION: 'table-editor.toggle-row-selection', + TABLE_EDITOR_TOGGLE_ALL_ROW_SELECTION: 'table-editor.toggle-all-row-selection', + TABLE_EDITOR_SELECT_ALL_IN_TABLE: 'table-editor.select-all-in-table', + TABLE_EDITOR_DELETE_SELECTED_ROWS: 'table-editor.delete-selected-rows', + TABLE_EDITOR_START_NAVIGATION_DOWN: 'table-editor.start-navigation-down', + TABLE_EDITOR_START_NAVIGATION_UP: 'table-editor.start-navigation-up', + TABLE_EDITOR_EXIT_SELECTION: 'table-editor.exit-selection', +} + +export type TableEditorShortcutId = + (typeof TABLE_EDITOR_SHORTCUT_IDS)[keyof typeof TABLE_EDITOR_SHORTCUT_IDS] + +export const tableEditorRegistry: RegistryDefinations = { + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_ROW, + label: 'Jump to first row', + sequence: ['Mod+ArrowUp'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_ROW]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_ROW, + label: 'Jump to last row', + sequence: ['Mod+ArrowDown'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_COL]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_FIRST_COL, + label: 'Jump to first column', + sequence: ['Mod+ArrowLeft'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_COL]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_JUMP_LAST_COL, + label: 'Jump to last column', + sequence: ['Mod+ArrowRight'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ROW_SELECTION]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ROW_SELECTION, + label: 'Toggle selection on current row', + sequence: ['Shift+Space'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ALL_ROW_SELECTION]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_TOGGLE_ALL_ROW_SELECTION, + label: 'Toggle selection on all displayed rows', + sequence: ['Mod+A'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_SELECT_ALL_IN_TABLE]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_SELECT_ALL_IN_TABLE, + label: 'Toggle selection on all rows in table', + sequence: ['Mod+Shift+A'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_DELETE_SELECTED_ROWS]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_DELETE_SELECTED_ROWS, + label: 'Delete selected rows', + sequence: ['Mod+Backspace'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_DOWN]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_DOWN, + label: 'Start grid navigation (down)', + sequence: ['ArrowDown'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_UP]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_START_NAVIGATION_UP, + label: 'Start grid navigation (up)', + sequence: ['ArrowUp'], + showInSettings: false, + options: { ignoreInputs: true }, + }, + [TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_EXIT_SELECTION]: { + id: TABLE_EDITOR_SHORTCUT_IDS.TABLE_EDITOR_EXIT_SELECTION, + label: 'Exit grid selection', + sequence: ['Escape'], + showInSettings: false, + options: { ignoreInputs: true }, + }, +} diff --git a/apps/studio/state/shortcuts/types.ts b/apps/studio/state/shortcuts/types.ts index da5f9502659..9230415dff1 100644 --- a/apps/studio/state/shortcuts/types.ts +++ b/apps/studio/state/shortcuts/types.ts @@ -99,3 +99,5 @@ export interface ShortcutDefinition { */ showInSettings?: boolean } + +export type RegistryDefinations = Record diff --git a/apps/studio/state/shortcuts/useShortcut.test.tsx b/apps/studio/state/shortcuts/useShortcut.test.tsx index 9275dd10bf8..5ae39cba95d 100644 --- a/apps/studio/state/shortcuts/useShortcut.test.tsx +++ b/apps/studio/state/shortcuts/useShortcut.test.tsx @@ -4,13 +4,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from './registry' import { useShortcut } from './useShortcut' -const { mockUseHotkeySequence, mockUseRegisterCommands, mockUseIsShortcutEnabled } = vi.hoisted( - () => ({ - mockUseHotkeySequence: vi.fn(), - mockUseRegisterCommands: vi.fn(), - mockUseIsShortcutEnabled: vi.fn(), - }) -) +const { + mockUseHotkeySequence, + mockUseRegisterCommands, + mockUseIsShortcutEnabled, + mockSetCommandMenuOpen, +} = vi.hoisted(() => ({ + mockUseHotkeySequence: vi.fn(), + mockUseRegisterCommands: vi.fn(), + mockUseIsShortcutEnabled: vi.fn(), + mockSetCommandMenuOpen: vi.fn(), +})) vi.mock('@tanstack/react-hotkeys', () => ({ useHotkeySequence: mockUseHotkeySequence, @@ -18,6 +22,7 @@ vi.mock('@tanstack/react-hotkeys', () => ({ vi.mock('ui-patterns/CommandMenu', () => ({ useRegisterCommands: mockUseRegisterCommands, + useSetCommandMenuOpen: () => mockSetCommandMenuOpen, })) vi.mock('./useIsShortcutEnabled', () => ({ @@ -204,6 +209,19 @@ describe('useShortcut', () => { expect(cb2).toHaveBeenCalledTimes(1) }) + it('command action closes the command menu when fired', () => { + const cb = vi.fn() + renderHook(() => + useShortcut(SHORTCUT_IDS.COMMAND_MENU_OPEN, cb, { registerInCommandMenu: true }) + ) + const action = mockUseRegisterCommands.mock.calls[0][1][0].action + + action() + + expect(mockSetCommandMenuOpen).toHaveBeenCalledWith(false) + expect(cb).toHaveBeenCalledTimes(1) + }) + it('command action identity is stable across renders', () => { const { rerender } = renderHook( ({ cb }: { cb: () => void }) => diff --git a/apps/studio/state/shortcuts/useShortcut.tsx b/apps/studio/state/shortcuts/useShortcut.tsx index d5aad427798..3ea2fa5e5e6 100644 --- a/apps/studio/state/shortcuts/useShortcut.tsx +++ b/apps/studio/state/shortcuts/useShortcut.tsx @@ -1,7 +1,7 @@ import { useHotkeySequence } from '@tanstack/react-hotkeys' import { Fragment, useCallback } from 'react' import { KeyboardShortcut } from 'ui' -import { useRegisterCommands } from 'ui-patterns/CommandMenu' +import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry' import type { ShortcutOptions } from './types' @@ -75,7 +75,11 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor const enabledInCommandMenu = enabled && (options?.registerInCommandMenu ?? false) const depsInCommandMenu = [enabled, def.label] const callbackRef = useLatest(callback) - const stableAction = useCallback(() => callbackRef.current(), [callbackRef]) + const setCommandMenuOpen = useSetCommandMenuOpen() + const stableAction = useCallback(() => { + setCommandMenuOpen(false) + callbackRef.current() + }, [callbackRef, setCommandMenuOpen]) useRegisterCommands( COMMAND_MENU_SECTIONS.SHORTCUTS, @@ -99,6 +103,7 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor { enabled: enabledInCommandMenu, deps: depsInCommandMenu, + sectionMeta: { priority: 1 }, } ) } diff --git a/apps/studio/styles/grid.css b/apps/studio/styles/grid.css index adf65a4fd6b..b4592b6a3c5 100644 --- a/apps/studio/styles/grid.css +++ b/apps/studio/styles/grid.css @@ -36,6 +36,11 @@ box-shadow: inset 0 0 0 1px #24b47e; } +.rdg:not(:focus-within) .rdg-cell[aria-selected='true'] { + box-shadow: none; + outline: none; +} + /* Cell with unsaved changes - warning (amber) background/text color */ .rdg-cell.rdg-cell--dirty { background-color: hsl(var(--warning-300) / 0.75);