Files
supabase/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx
Gildas Garcia 243e079a2c chore: remove _Shadcn_ suffix from Command components (#46153)
## Problem

The `_Shadcn_` suffix isn't needed anymore on `Command` components

## Solution

- Remove the `_Shadcn_` suffix
- Simplify UI package exports
- Apply prettier

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

## Summary by CodeRabbit

* **Refactor**
* Simplified command component imports and exports across the UI library
by removing internal naming aliases and adopting direct component
references. Updated the public UI package barrel export to use wildcard
re-exports for cleaner API surface.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46153?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-20 15:45:32 +02:00

376 lines
11 KiB
TypeScript

import type { PGColumn } from '@supabase/pg-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { AlertTriangle, Code, Loader2, Table2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef } from 'react'
import { cn, CommandEmpty, CommandGroup, CommandItem, CommandList } from 'ui'
import { CodeBlock } from 'ui-patterns/CodeBlock'
import type { CommandOptions } from 'ui-patterns/CommandMenu'
import {
Breadcrumb,
CommandHeader,
CommandMenuInput,
CommandWrapper,
escapeAttributeSelector,
generateCommandClassNames,
PageType,
useCommandFilterState,
useCommandMenuOpen,
useRegisterCommands,
useRegisterPage,
useSetCommandMenuSize,
useSetPage,
} from 'ui-patterns/CommandMenu'
import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils'
import { orderCommandSectionsByPriority } from '@/components/interfaces/App/CommandMenu/ordering'
import { useSqlSnippetsQuery, type SqlSnippet } from '@/data/content/sql-snippets-query'
import { usePrefetchTables, useTablesQuery, type TablesData } from '@/data/tables/tables-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useProtectedSchemas } from '@/hooks/useProtectedSchemas'
import { useProfile } from '@/lib/profile'
export function useSqlEditorGotoCommands(options?: CommandOptions) {
let { ref } = useParams()
ref ||= '_'
useRegisterCommands(
COMMAND_MENU_SECTIONS.NAVIGATE,
[
{
id: 'nav-sql-editor',
name: 'SQL Editor',
route: `/project/${ref}/sql`,
defaultHidden: true,
},
],
{ ...options, deps: [ref] }
)
}
const SNIPPET_PAGE_NAME = 'Snippets'
export function useSnippetCommands() {
const { data: project } = useSelectedProjectQuery()
const setPage = useSetPage()
useRegisterPage(
SNIPPET_PAGE_NAME,
{
type: PageType.Component,
component: () => <RunSnippetPage />,
},
{ enabled: !!project }
)
useRegisterCommands(
COMMAND_MENU_SECTIONS.SQL,
[
{
id: 'run-snippet',
name: 'Run snippet...',
icon: () => <Code />,
action: () => setPage(SNIPPET_PAGE_NAME),
},
],
{
enabled: !!project,
orderSection: orderCommandSectionsByPriority,
sectionMeta: { priority: 3 },
}
)
}
function RunSnippetPage() {
const { ref } = useParams()
const {
data: snippetPages,
isPending: isLoading,
isError,
isSuccess,
} = useSqlSnippetsQuery({
projectRef: ref,
})
const snippets = snippetPages?.pages.flatMap((page) => page.contents)
const { profile } = useProfile()
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
PermissionAction.CREATE,
'user_content',
{
resource: { type: 'sql', owner_id: profile?.id },
subject: { id: profile?.id },
}
)
useSetCommandMenuSize('xlarge')
return (
<CommandWrapper>
<CommandHeader>
<Breadcrumb />
<CommandMenuInput autoFocus />
</CommandHeader>
{isLoading && <LoadingState />}
{isError && <ErrorState />}
{isSuccess && (!snippets || snippets.length === 0) && (
<EmptyState projectRef={ref} canCreateNew={canCreateSQLSnippet} />
)}
{isSuccess && !!snippets && snippets.length > 0 && (
<SnippetSelector projectRef={ref} canCreateNew={canCreateSQLSnippet} snippets={snippets} />
)}
</CommandWrapper>
)
}
function LoadingState() {
return (
<div className="p-6">
<p className="text-center">
<Loader2 className="inline-block mr-2 animate-spin" />
Loading...
</p>
</div>
)
}
function ErrorState() {
return (
<div className="p-6">
<p className="text-center">
<AlertTriangle className="inline-block mr-2" />
Couldn&apos;t load snippets
</p>
</div>
)
}
function EmptyState({
projectRef,
canCreateNew,
}: {
projectRef: string | undefined
canCreateNew: boolean
}) {
const router = useRouter()
return (
<div className="p-6">
<p className="mb-2 text-center">No snippets found.</p>
<CommandList className="py-2">
<CommandGroup>
<CommandItem
id="create-snippet"
className={generateCommandClassNames(false)}
onSelect={() => router.push(`/project/${projectRef ?? '_'}/sql/new`)}
>
{canCreateNew ? 'Create new snippet' : 'Run new SQL'}
</CommandItem>
</CommandGroup>
</CommandList>
</div>
)
}
function SnippetSelector({
projectRef,
snippets,
canCreateNew,
}: {
projectRef: string | undefined
snippets: Array<SqlSnippet> | undefined
canCreateNew: boolean
}) {
const router = useRouter()
const selectedValue = useCommandFilterState((state) => state.value)
const selectedSnippet = snippets?.find((snippet) => snippetValue(snippet) === selectedValue)
const isSQLSnippet = selectedSnippet?.type === 'sql'
return (
<div className="w-full grow min-h-0 grid gap-4 md:grid-cols-2">
<CommandList
className={cn(
'h-full! min-h-0 max-h-[unset] py-2 overflow-hidden',
'*:[[cmdk-list-sizer]]:h-full *:[[cmdk-list-sizer]]:flex *:[[cmdk-list-sizer]]:flex-col'
)}
>
{!!snippets && snippets.length > 0 && (
<CommandGroup className="grow min-h-0 overflow-auto">
{snippets.map((snippet) => (
<CommandItem
key={snippet.id}
id={`${snippet.id}-${snippet.name}`}
className={generateCommandClassNames(false)}
value={snippetValue(snippet)}
onSelect={() => void router.push(`/project/${projectRef ?? '_'}/sql/${snippet.id}`)}
>
{snippet.name}
</CommandItem>
))}
</CommandGroup>
)}
{canCreateNew && (
<div className="min-h-fit grow-0">
<hr className="mt-4 mb-2 mx-2" />
<CommandGroup forceMount={true}>
<CommandItem
id="create-snippet"
className={generateCommandClassNames(false)}
onSelect={() => router.push(`/project/${projectRef ?? '_'}/sql/new`)}
forceMount={true}
>
Create new snippet
</CommandItem>
</CommandGroup>
</div>
)}
</CommandList>
<CodeBlock
language="sql"
value={isSQLSnippet ? selectedSnippet?.content?.unchecked_sql : ''}
wrapperClassName="hidden md:block"
className="w-full h-full border-0 [&>code]:overflow-scroll [&>code]:block [&>code]:w-full [&>code]:h-full"
hideCopy
/>
</div>
)
}
function snippetValue(snippet: SqlSnippet) {
if (snippet.type !== 'sql') return ''
return escapeAttributeSelector(
`${snippet.id}-${snippet.name}-${snippet?.content?.unchecked_sql.slice(0, 30)}`
).toLowerCase()
}
const QUERY_TABLE_PAGE_NAME = 'Query a table'
export function useQueryTableCommands(options?: CommandOptions) {
const { data: project } = useSelectedProjectQuery()
const setPage = useSetPage()
const commandMenuOpen = useCommandMenuOpen()
const commandMenuPreviouslyOpen = useRef(commandMenuOpen)
const commandMenuJustOpened = commandMenuOpen && !commandMenuPreviouslyOpen.current
commandMenuPreviouslyOpen.current = commandMenuOpen
const prefetchTables = usePrefetchTables({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
useEffect(() => {
if (project && commandMenuJustOpened) {
prefetchTables(undefined, true)
}
}, [project, prefetchTables, commandMenuJustOpened])
useRegisterPage(
QUERY_TABLE_PAGE_NAME,
{
type: PageType.Component,
component: TableSelector,
},
{ enabled: !!project }
)
useRegisterCommands(
COMMAND_MENU_SECTIONS.SQL,
[
{
id: 'query-table',
name: 'Query a table...',
icon: () => <Table2 />,
action: () => setPage(QUERY_TABLE_PAGE_NAME),
},
],
{ ...options, enabled: (options?.enabled ?? true) && !!project }
)
}
function TableSelector() {
const router = useRouter()
const { data: project } = useSelectedProjectQuery()
const { data: protectedSchemas } = useProtectedSchemas()
const {
data: tablesData,
isPending: isLoading,
isError,
isSuccess,
} = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
includeColumns: true,
})
const tables = useMemo(() => {
return tablesData?.filter((table) => !protectedSchemas.find((s) => s.name === table.schema))
}, [tablesData, protectedSchemas])
return (
<CommandWrapper>
<CommandHeader>
<Breadcrumb />
<CommandMenuInput autoFocus />
</CommandHeader>
<CommandList>
{isLoading && <LoadingState />}
{isError && <ErrorState />}
{isSuccess && (
<>
<CommandEmpty />
<CommandGroup>
{tables?.map((table) => (
<CommandItem
key={table.id}
className={generateCommandClassNames(false)}
value={escapeAttributeSelector(`${table.schema}.${table.name}`)}
onSelect={() => {
router.push(
`/project/${project?.ref ?? '_'}/sql/new?content=${encodeURIComponent(generateSelectStatement(table))}`
)
}}
>
{`${table.schema}.${table.name}`}
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandWrapper>
)
}
function generateSelectStatement(table: TablesData[number] & { columns?: Array<PGColumn> }) {
return `
select ${
!table.columns
? '*'
: `
${table.columns.map((column) => `\t${column.name}`).join(',\n')}`
}
from ${formatTableIdentifier(table)}
-- where
-- order by
-- limit
;
`.trim()
}
// Not a perfectly spec-compliant regex , since Postgres also allows non-Latin
// letters and letters with diacritical marks, but quoting them defensively
// is easier than writing the regex. ¯\_(ツ)_/¯
const VALID_UNQUOTED_IDENTIFIER_REGEX = /^[a-z_][a-z0-9_$]*$/
function formatTableIdentifier(table: TablesData[number]) {
const schema = VALID_UNQUOTED_IDENTIFIER_REGEX.test(table.schema)
? table.schema
: `"${table.schema}"`
const tableName = VALID_UNQUOTED_IDENTIFIER_REGEX.test(table.name)
? table.name
: `"${table.name}"`
return `${schema}.${tableName}`
}