diff --git a/apps/studio/components/interfaces/DatabaseNavShortcuts.tsx b/apps/studio/components/interfaces/DatabaseNavShortcuts.tsx new file mode 100644 index 00000000000..1b524aeaa73 --- /dev/null +++ b/apps/studio/components/interfaces/DatabaseNavShortcuts.tsx @@ -0,0 +1,96 @@ +import { useRouter } from 'next/router' +import { useCallback, useMemo } from 'react' + +import { useGenerateDatabaseMenu } from '@/components/layouts/DatabaseLayout/DatabaseMenu.utils' +import { SHORTCUT_IDS, type ShortcutId } from '@/state/shortcuts/registry' +import { useShortcut } from '@/state/shortcuts/useShortcut' + +export const DatabaseNavShortcuts = () => { + const router = useRouter() + const groups = useGenerateDatabaseMenu() + + const urlByShortcut = useMemo(() => { + const map = new Map() + for (const group of groups) { + for (const item of group.items) { + if (item.shortcutId && item.url) map.set(item.shortcutId, item.url) + } + } + return map + }, [groups]) + + const navigate = useCallback( + (id: ShortcutId) => { + const url = urlByShortcut.get(id) + if (url) router.push(url) + }, + [router, urlByShortcut] + ) + + useShortcut(SHORTCUT_IDS.NAV_DATABASE_TABLES, () => navigate(SHORTCUT_IDS.NAV_DATABASE_TABLES), { + enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_TABLES), + }) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS) } + ) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_TRIGGERS, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_TRIGGERS), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_TRIGGERS) } + ) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_INDEXES, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_INDEXES), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_INDEXES) } + ) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS) } + ) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER) } + ) + useShortcut(SHORTCUT_IDS.NAV_DATABASE_ROLES, () => navigate(SHORTCUT_IDS.NAV_DATABASE_ROLES), { + enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_ROLES), + }) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_BACKUPS, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_BACKUPS), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_BACKUPS) } + ) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS) } + ) + useShortcut(SHORTCUT_IDS.NAV_DATABASE_TYPES, () => navigate(SHORTCUT_IDS.NAV_DATABASE_TYPES), { + enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_TYPES), + }) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS) } + ) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES) } + ) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_SETTINGS, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_SETTINGS), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_SETTINGS) } + ) + useShortcut( + SHORTCUT_IDS.NAV_DATABASE_REPLICATION, + () => navigate(SHORTCUT_IDS.NAV_DATABASE_REPLICATION), + { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_REPLICATION) } + ) + + return null +} diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx index 30db23926f0..7365cd2c3c3 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx @@ -3,6 +3,7 @@ import type { PropsWithChildren } from 'react' import { ProjectLayout } from '../ProjectLayout' import { useGenerateDatabaseMenu } from './DatabaseMenu.utils' +import { DatabaseNavShortcuts } from '@/components/interfaces/DatabaseNavShortcuts' import { ProductMenu } from '@/components/ui/ProductMenu' import { withAuth } from '@/hooks/misc/withAuth' @@ -26,6 +27,7 @@ const DatabaseLayout = ({ children, title }: PropsWithChildren} isBlocking={false} > + {children} ) diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx index b42d5ce4e7d..f8f93ed79a6 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx @@ -12,6 +12,7 @@ import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' const ExternalLinkIcon = @@ -42,24 +43,70 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { { title: 'Database Management', items: [ - { name: 'Schema Visualizer', key: 'schemas', url: getDatabaseURL('schemas') }, - { name: 'Tables', key: 'tables', url: getDatabaseURL('tables') }, - { name: 'Functions', key: 'functions', url: getDatabaseURL('functions') }, - { name: 'Triggers', key: 'triggers', url: getDatabaseURL('triggers/data') }, - { name: 'Enumerated Types', key: 'types', url: getDatabaseURL('types') }, - { name: 'Extensions', key: 'extensions', url: getDatabaseURL('extensions') }, - { name: 'Indexes', key: 'indexes', url: getDatabaseURL('indexes') }, - { name: 'Publications', key: 'publications', url: getDatabaseURL('publications') }, + { + name: 'Schema Visualizer', + key: 'schemas', + url: getDatabaseURL('schemas'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER, + }, + { + name: 'Tables', + key: 'tables', + url: getDatabaseURL('tables'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_TABLES, + }, + { + name: 'Functions', + key: 'functions', + url: getDatabaseURL('functions'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS, + }, + { + name: 'Triggers', + key: 'triggers', + url: getDatabaseURL('triggers/data'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_TRIGGERS, + }, + { + name: 'Enumerated Types', + key: 'types', + url: getDatabaseURL('types'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_TYPES, + }, + { + name: 'Extensions', + key: 'extensions', + url: getDatabaseURL('extensions'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS, + }, + { + name: 'Indexes', + key: 'indexes', + url: getDatabaseURL('indexes'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_INDEXES, + }, + { + name: 'Publications', + key: 'publications', + url: getDatabaseURL('publications'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS, + }, ], }, { title: 'Configuration', items: [ - showRoles && { name: 'Roles', key: 'roles', url: getDatabaseURL('roles') }, + showRoles && { + name: 'Roles', + key: 'roles', + url: getDatabaseURL('roles'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_ROLES, + }, columnLevelPrivileges && { name: 'Column Privileges', key: 'column-privileges', url: getDatabaseURL('column-privileges'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES, }, { name: 'Policies', @@ -67,7 +114,12 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { url: `/project/${ref}/auth/policies`, rightIcon: ExternalLinkIcon, }, - { name: 'Settings', key: 'settings', url: getDatabaseURL('settings') }, + { + name: 'Settings', + key: 'settings', + url: getDatabaseURL('settings'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_SETTINGS, + }, ].filter(Boolean) as ProductMenuGroupItem[], }, { @@ -79,13 +131,20 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { key: 'replication', url: getDatabaseURL('replication'), label: enablePgReplicate ? 'New' : undefined, + shortcutId: SHORTCUT_IDS.NAV_DATABASE_REPLICATION, }, IS_PLATFORM && { name: 'Backups', key: 'backups', url: pitrEnabled ? getDatabaseURL('backups/pitr') : getDatabaseURL('backups/scheduled'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_BACKUPS, + }, + { + name: 'Migrations', + key: 'migrations', + url: getDatabaseURL('migrations'), + shortcutId: SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS, }, - { name: 'Migrations', key: 'migrations', url: getDatabaseURL('migrations') }, showWrappers && { name: 'Wrappers', key: 'wrappers', diff --git a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts index 477022c9b23..54a8f3fcffc 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts +++ b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts @@ -1,5 +1,7 @@ import { ReactNode } from 'react' +import type { ShortcutId } from '@/state/shortcuts/registry' + export interface ProductMenuGroup { title?: string /** Set to "main" if page is on a '/' route */ @@ -25,6 +27,7 @@ export interface ProductMenuGroupItem { childIcon?: ReactNode childItems?: ProductMenuGroupItem[] pages?: string[] + shortcutId?: ShortcutId } /** diff --git a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx index eb751c6891e..53b02d68042 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx +++ b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { Badge, Button, Menu } from 'ui' import { ProductMenuGroupItem } from './ProductMenu.types' +import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip' interface ProductMenuItemProps { item: ProductMenuGroupItem @@ -18,16 +19,26 @@ export const ProductMenuItem = ({ hoverText = '', onClick, }: ProductMenuItemProps) => { - const { name = '', url = '', icon, rightIcon, isExternal, label, disabled } = item + const { name = '', url = '', icon, rightIcon, isExternal, label, disabled, shortcutId } = item + + const labelNode = shortcutId ? ( + + {name} + + ) : ( + {name} + ) const menuItem = (
- {name} + {labelNode} {label !== undefined && ( = { // Shared list-page shortcut registration ...listPageRegistry, + + // Database sub-page navigation chord registration + ...databaseNavRegistry, } diff --git a/apps/studio/state/shortcuts/registry/database-nav.ts b/apps/studio/state/shortcuts/registry/database-nav.ts new file mode 100644 index 00000000000..9a437c3b390 --- /dev/null +++ b/apps/studio/state/shortcuts/registry/database-nav.ts @@ -0,0 +1,117 @@ +import { RegistryDefinations } from '../types' + +/** + * Contextual chords for jumping between Database sub-pages — `D + `. + * + * Active only while DatabaseLayout is mounted (i.e. the user is somewhere + * under `/project//database/*`). The chord intentionally lives on the + * page rather than globally so the leading `D` doesn't burn a global key for + * a destination most users only care about while already in the section. + * + */ +export const DATABASE_NAV_SHORTCUT_IDS = { + NAV_DATABASE_TABLES: 'nav.database-tables', + NAV_DATABASE_FUNCTIONS: 'nav.database-functions', + NAV_DATABASE_TRIGGERS: 'nav.database-triggers', + NAV_DATABASE_INDEXES: 'nav.database-indexes', + NAV_DATABASE_EXTENSIONS: 'nav.database-extensions', + NAV_DATABASE_SCHEMA_VISUALIZER: 'nav.database-schema-visualizer', + NAV_DATABASE_ROLES: 'nav.database-roles', + NAV_DATABASE_BACKUPS: 'nav.database-backups', + NAV_DATABASE_MIGRATIONS: 'nav.database-migrations', + NAV_DATABASE_TYPES: 'nav.database-types', + NAV_DATABASE_PUBLICATIONS: 'nav.database-publications', + NAV_DATABASE_COLUMN_PRIVILEGES: 'nav.database-column-privileges', + NAV_DATABASE_SETTINGS: 'nav.database-settings', + NAV_DATABASE_REPLICATION: 'nav.database-replication', +} + +export type DatabaseNavShortcutId = + (typeof DATABASE_NAV_SHORTCUT_IDS)[keyof typeof DATABASE_NAV_SHORTCUT_IDS] + +export const databaseNavRegistry: RegistryDefinations = { + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TABLES]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TABLES, + label: 'Go to Tables', + sequence: ['D', 'T'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS, + label: 'Go to Functions', + sequence: ['D', 'F'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TRIGGERS]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TRIGGERS, + label: 'Go to Triggers', + sequence: ['D', 'R'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_INDEXES]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_INDEXES, + label: 'Go to Indexes', + sequence: ['D', 'I'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS, + label: 'Go to Extensions', + sequence: ['D', 'X'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER, + label: 'Go to Schema Visualizer', + sequence: ['D', 'V'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_ROLES]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_ROLES, + label: 'Go to Roles', + sequence: ['D', 'O'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_BACKUPS]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_BACKUPS, + label: 'Go to Backups', + sequence: ['D', 'B'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS, + label: 'Go to Migrations', + sequence: ['D', 'M'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TYPES]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TYPES, + label: 'Go to Enumerated Types', + sequence: ['D', 'E'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS, + label: 'Go to Publications', + sequence: ['D', 'U'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES, + label: 'Go to Column Privileges', + sequence: ['D', 'C'], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_SETTINGS]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_SETTINGS, + label: 'Go to Database Settings', + sequence: ['D', ','], + showInSettings: false, + }, + [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_REPLICATION]: { + id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_REPLICATION, + label: 'Go to Replication', + sequence: ['D', 'L'], + showInSettings: false, + }, +}