Merge dnywh/studio/sql-editor-ia into warehouse-wholesale

Bring SQL Editor query context bar (connection, search path, role) and
integrate Warehouse query target into those controls instead of a
standalone Postgres/Warehouse toggle.
This commit is contained in:
Danny White committed 2026-07-09 17:10:37 -04:00
commit 0100d57e25
35 files changed
+1517 -746

No files matched your search

@@ -13,6 +13,7 @@ export interface RoleImpersonationPopoverProps {
variant?: 'regular' | 'connected-on-right' | 'connected-on-left' | 'connected-on-both'
align?: 'center' | 'start' | 'end'
disallowAuthenticatedOption?: boolean
className?: string
}
export const RoleImpersonationPopover = ({
@@ -21,6 +22,7 @@ export const RoleImpersonationPopover = ({
variant = 'regular',
align = 'end',
disallowAuthenticatedOption = false,
className,
}: RoleImpersonationPopoverProps) => {
const state = useRoleImpersonationStateSnapshot()
@@ -38,7 +40,8 @@ export const RoleImpersonationPopover = ({
'h-[26px] pr-3 gap-0',
variant === 'connected-on-right' && 'rounded-r-none border-r-0',
variant === 'connected-on-left' && 'rounded-l-none border-l-0',
variant === 'connected-on-both' && 'rounded-none border-x-0'
variant === 'connected-on-both' && 'rounded-none border-x-0',
className
)}
>
<div className="flex items-center gap-1">
@@ -100,7 +100,7 @@ export const ASSISTANT_TEMPLATES = [
]
export const ROWS_PER_PAGE_OPTIONS = [
{ value: -1, label: 'No limit' },
{ value: -1, label: 'None' },
{ value: 100, label: '100 rows' },
{ value: 500, label: '500 rows' },
{ value: 1000, label: '1,000 rows' },
@@ -9,32 +9,17 @@ import {
import { wrapWithRollback } from '@supabase/pg-meta/src/query'
import { useQueryClient } from '@tanstack/react-query'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
import { ChevronUp, Loader2 } from 'lucide-react'
import { Loader2 } from 'lucide-react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/router'
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui'
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
import { RunQueryWarningModal } from './RunQueryWarningModal'
import {
generateSnippetTitle,
ROWS_PER_PAGE_OPTIONS,
sqlAiDisclaimerComment,
untitledSnippetTitle,
} from './SQLEditor.constants'
@@ -56,7 +41,13 @@ import {
isUpdateWithoutWhere,
suffixWithLimit,
} from './SQLEditor.utils'
import { useSqlQueryTarget } from './SqlWarehouseTargetToggle'
import { SqlEditorQueryBar } from './SqlEditorQueryBar'
import { useSqlQueryTarget } from './SqlEditorQueryTargetSelector'
import {
DEFAULT_SQL_EDITOR_SEARCH_PATH,
getSqlEditorSearchPathStorageKey,
} from './SqlEditorSearchPathSelector'
import { resolveSqlWarehouseResultSource } from './SqlEditorWarehouseDemo'
import { useAddDefinitions } from './useAddDefinitions'
import { UtilityPanel } from './UtilityPanel/UtilityPanel'
import {
@@ -66,7 +57,6 @@ import {
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils'
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget'
import { GridFooter } from '@/components/ui/GridFooter'
import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation'
import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query'
import { constructHeaders, isValidConnString } from '@/data/fetchers'
@@ -74,6 +64,7 @@ import { lintKeys } from '@/data/lint/keys'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { isError } from '@/data/utils/error-check'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -128,6 +119,10 @@ export const SQLEditor = () => {
const sessionSnap = useSqlEditorSessionSnapshot()
const diffRequest = useSqlEditorDiffRequestSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const [searchPath] = useLocalStorageQuery(
getSqlEditorSearchPathStorageKey(ref ?? 'unknown'),
DEFAULT_SQL_EDITOR_SEARCH_PATH
)
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
const { aiOptInLevel } = useOrgAiOptInLevel()
const sqlQueryTarget = useSqlQueryTarget()
@@ -209,7 +204,6 @@ export const SQLEditor = () => {
const id = !urlId || urlId === 'new' ? generatedId : urlId
const limit = sessionSnap.limit
const results = sessionSnap.results[id]?.[0]
const snippetIsLoading = !(
id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined
)
@@ -238,7 +232,7 @@ export const SQLEditor = () => {
const { mutate: execute, isPending: isExecuting } = useExecuteSqlMutation({
onSuccess(data, vars) {
if (id) {
sessionSnap.addResult(id, data.result, vars.autoLimit)
sessionSnap.addResult(id, data.result, vars.autoLimit, vars.warehouseResultSource)
if (!disablePrettyExplain && isExplainQuery(data.result)) {
sessionSnap.addExplainResult(id, data.result)
@@ -286,7 +280,7 @@ export const SQLEditor = () => {
}
}
sessionSnap.addResultError(id, error, vars.autoLimit)
sessionSnap.addResultError(id, error, vars.autoLimit, vars.warehouseResultSource)
}
refocusEditorAfterRunIfNeeded()
@@ -436,12 +430,18 @@ export const SQLEditor = () => {
const userSql = rawSql(sql)
const { appendAutoLimit } = checkIfAppendLimitRequired(userSql, limit)
const formattedSql = suffixWithLimit(userSql, limit)
const warehouseResultSource = resolveSqlWarehouseResultSource(
userSql,
searchPath,
sqlQueryTarget
)
execute({
projectRef: project.ref,
connectionString: connectionString,
sql: wrapWithRoleImpersonation(formattedSql, impersonatedRoleState),
autoLimit: appendAutoLimit ? limit : undefined,
warehouseResultSource,
isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role),
isStatementTimeoutDisabled: true,
contextualInvalidation: true,
@@ -467,6 +467,8 @@ export const SQLEditor = () => {
databases,
eventTriggers,
limit,
searchPath,
sqlQueryTarget,
track,
]
)
@@ -548,7 +550,7 @@ export const SQLEditor = () => {
databaseSelectorState.selectedDatabaseId,
databases,
lineHighlights,
sessionSnap,
snapV2,
])
useShortcut(SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, executeExplainQuery, {
@@ -885,6 +887,121 @@ export const SQLEditor = () => {
}
}, [isDiffOpen, isDiffEditorMounted])
const queryResult = sessionSnap.results[id]?.[0]
const explainResult = sessionSnap.explainResults[id]
const hasExplainResult =
explainResult?.error !== undefined || (explainResult?.rows?.length ?? 0) > 0
const showUtilityPanel =
isExecuting || isExplainExecuting || queryResult !== undefined || hasExplainResult
const sqlEditorWorkspace = isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="animate-spin text-brand" />
</div>
) : (
<>
{isDiffOpen && (
<div className="w-full h-full">
<DiffEditor
language="pgsql"
original={defaultSqlDiff.original}
modified={defaultSqlDiff.modified}
onMount={(editor) => {
diffEditorRef.current = editor
setIsDiffEditorMounted(true)
}}
/>
{showWidget && (
<ResizableAIWidget
editor={diffEditorRef.current!}
id="ask-ai-diff"
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection || defaultSqlDiff.modified,
afterSelection: promptState.afterSelection,
})
}}
onAccept={acceptAiHandler}
onReject={discardAiHandler}
onCancel={resetPrompt}
isDiffVisible={true}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
</div>
)}
<div key={id} className="w-full h-full relative">
<MonacoEditor
autoFocus
placeholder={
!promptState.isOpen && !editorRef.current?.getValue()
? 'Hit ' +
(os === 'macos' ? 'CMD+SHIFT+K' : `CTRL+SHIFT+K`) +
' to generate query or just start typing'
: ''
}
id={id}
snippetName={
urlId === 'new'
? generatedNewSnippetName
: (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName)
}
className={cn(isDiffOpen && 'hidden')}
editorRef={editorRef}
monacoRef={monacoRef}
executeQuery={executeQuery}
executeExplainQuery={executeExplainQuery}
showExplainAction={!disablePrettyExplain}
prettifyQuery={prettifyQuery}
onHasSelection={setHasSelection}
onMount={onMount}
onPrompt={({
selection,
beforeSelection,
afterSelection,
startLineNumber,
endLineNumber,
}) => {
setPromptState((prev) => ({
...prev,
isOpen: true,
selection,
beforeSelection,
afterSelection,
startLineNumber,
endLineNumber,
}))
}}
/>
{editorRef.current && promptState.isOpen && !isDiffOpen && (
<ResizableAIWidget
editor={editorRef.current}
id="ask-ai"
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection,
afterSelection: promptState.afterSelection,
})
}}
onCancel={resetPrompt}
isDiffVisible={false}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
</div>
</>
)
return (
<>
<RunQueryWarningModal
@@ -918,140 +1035,35 @@ export const SQLEditor = () => {
}}
/>
<div className="flex h-full">
<ResizablePanelGroup
className="relative"
orientation="vertical"
autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE}
>
<ResizablePanel defaultSize="50" maxSize="70">
<div className="grow overflow-y-auto border-b h-full">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="animate-spin text-brand" />
</div>
) : (
<>
{isDiffOpen && (
<div className="w-full h-full">
<DiffEditor
language="pgsql"
original={defaultSqlDiff.original}
modified={defaultSqlDiff.modified}
onMount={(editor) => {
diffEditorRef.current = editor
setIsDiffEditorMounted(true)
}}
/>
{showWidget && (
<ResizableAIWidget
editor={diffEditorRef.current!}
id="ask-ai-diff"
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection || defaultSqlDiff.modified,
afterSelection: promptState.afterSelection,
})
}}
onAccept={acceptAiHandler}
onReject={discardAiHandler}
onCancel={resetPrompt}
isDiffVisible={true}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
</div>
)}
<div key={id} className="w-full h-full relative">
<MonacoEditor
autoFocus
placeholder={
!promptState.isOpen && !editorRef.current?.getValue()
? 'Hit ' +
(os === 'macos' ? 'CMD+SHIFT+K' : `CTRL+SHIFT+K`) +
' to generate query or just start typing'
: ''
}
id={id}
snippetName={
urlId === 'new'
? generatedNewSnippetName
: (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName)
}
className={cn(isDiffOpen && 'hidden')}
editorRef={editorRef}
monacoRef={monacoRef}
executeQuery={executeQuery}
executeExplainQuery={executeExplainQuery}
showExplainAction={!disablePrettyExplain}
prettifyQuery={prettifyQuery}
onHasSelection={setHasSelection}
onMount={onMount}
onPrompt={({
selection,
beforeSelection,
afterSelection,
startLineNumber,
endLineNumber,
}) => {
setPromptState((prev) => ({
...prev,
isOpen: true,
selection,
beforeSelection,
afterSelection,
startLineNumber,
endLineNumber,
}))
}}
/>
{editorRef.current && promptState.isOpen && !isDiffOpen && (
<ResizableAIWidget
editor={editorRef.current}
id="ask-ai"
value={promptInput}
onChange={setPromptInput}
onSubmit={(prompt: string) => {
handlePrompt(prompt, {
beforeSelection: promptState.beforeSelection,
selection: promptState.selection,
afterSelection: promptState.afterSelection,
})
}}
onCancel={resetPrompt}
isDiffVisible={false}
isLoading={isCompletionLoading}
startLineNumber={Math.max(0, promptState.startLineNumber)}
endLineNumber={promptState.endLineNumber}
/>
)}
</div>
</>
)}
</div>
</ResizablePanel>
<div className="flex h-full flex-col">
{!isLoading && (
<SqlEditorQueryBar
id={id}
isExecuting={isExecuting}
isDisabled={isDiffOpen}
hasSelection={hasSelection}
prettifyQuery={prettifyQuery}
executeQuery={executeQueryFromButton}
/>
)}
{showUtilityPanel ? (
<ResizablePanelGroup
className="relative min-h-0 flex-1"
orientation="vertical"
autoSaveId={LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE}
>
<ResizablePanel defaultSize="50" maxSize="70">
<div className="grow overflow-y-auto h-full">{sqlEditorWorkspace}</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizableHandle withHandle />
<ResizablePanel defaultSize="50" maxSize="70">
{isLoading ? (
<div className="flex h-full w-full items-center justify-center">
<Loader2 className="animate-spin text-brand" />
</div>
) : (
<ResizablePanel defaultSize="50" maxSize="70">
<UtilityPanel
id={id}
isExecuting={isExecuting}
isExplainExecuting={isExplainExecuting}
isDisabled={isDiffOpen}
hasSelection={hasSelection}
prettifyQuery={prettifyQuery}
executeQuery={executeQueryFromButton}
executeExplainQuery={executeExplainQuery}
showExplainTab={!disablePrettyExplain}
onDebug={onDebug}
@@ -1059,71 +1071,11 @@ export const SQLEditor = () => {
activeTab={activeUtilityTab}
onActiveTabChange={setActiveUtilityTab}
/>
)}
</ResizablePanel>
<div className="h-9">
{results?.rows !== undefined && !isExecuting && (
<GridFooter className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger>
<p className="text-xs">
<span className="text-foreground">
{results.rows.length} row{results.rows.length > 1 ? 's' : ''}
</span>
<span className="text-foreground-lighter ml-1">
{results.autoLimit !== undefined &&
` (Limited to only ${results.autoLimit} rows)`}
</span>
</p>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="flex flex-col gap-y-1">
<span>
Results are automatically limited to preserve browser performance, in
particular if your query returns an exceptionally large number of rows.
</span>
<span className="text-foreground-light">
You may change or remove this limit from the dropdown on the right
</span>
</p>
</TooltipContent>
</Tooltip>
{sqlQueryTarget === 'warehouse' && (
<p className="text-xs text-foreground-light">Served by: Warehouse</p>
)}
</div>
{results.autoLimit !== undefined && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" iconRight={<ChevronUp size={14} />}>
Limit results to:{' '}
{
ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === sessionSnap.limit)
?.label
}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" align="end">
<DropdownMenuRadioGroup
value={sessionSnap.limit.toString()}
onValueChange={(val) => sessionSnap.setLimit(Number(val))}
>
{ROWS_PER_PAGE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.label} value={option.value.toString()}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</GridFooter>
)}
</div>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="relative min-h-0 flex-1 overflow-y-auto">{sqlEditorWorkspace}</div>
)}
</div>
</>
)
@@ -0,0 +1,345 @@
import { Hotkey } from '@tanstack/react-hotkeys'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { AlignLeft, ChevronDown, MoreVertical } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import {
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
KeyboardShortcut,
Popover,
PopoverContent,
PopoverTrigger,
} from 'ui'
import { useSnapshot } from 'valtio'
import { SqlEditorQueryTargetSelector, useSqlQueryTarget } from './SqlEditorQueryTargetSelector'
import {
DEFAULT_SQL_EDITOR_SEARCH_PATH,
getSqlEditorSearchPathStorageKey,
SqlEditorSearchPathSelector,
} from './SqlEditorSearchPathSelector'
import { sqlEditorWarehouseDemoStore } from './SqlEditorWarehouseDemo'
import { AutosaveStatus } from './UtilityPanel/AutosaveStatus'
import { SqlRunButton } from './UtilityPanel/RunButton'
import { SqlSaveButton } from './UtilityPanel/SaveButton'
import SavingIndicator from './UtilityPanel/SavingIndicator'
import { SqlEditorLimitSelector } from './UtilityPanel/SqlEditorLimitSelector'
import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { IS_PLATFORM } from '@/lib/constants'
import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state'
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
export type SqlEditorQueryBarProps = {
id: string
isExecuting?: boolean
isDisabled?: boolean
hasSelection?: boolean
prettifyQuery: () => void
executeQuery: () => void
}
const SEPARATE_QUERY_CONTROLS_INLINE_MIN_WIDTH = 896
export function SqlEditorQueryBar({
id,
isExecuting = false,
isDisabled = false,
hasSelection = false,
prettifyQuery,
executeQuery,
}: SqlEditorQueryBarProps) {
const { ref } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
const sessionSnap = useSqlEditorSessionSnapshot()
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const warehouseDemoSnap = useSnapshot(sqlEditorWarehouseDemoStore)
const queryBarRef = useRef<HTMLDivElement>(null)
const [areSeparateQueryControlsCollapsed, setAreSeparateQueryControlsCollapsed] = useState(false)
const [intelliSenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
true
)
const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string),
''
)
const [searchPath] = useLocalStorageQuery(
getSqlEditorSearchPathStorageKey(ref ?? 'unknown'),
DEFAULT_SQL_EDITOR_SEARCH_PATH
)
const queryTarget = useSqlQueryTarget()
const snippet = snapV2.snippets[id]
const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false
const currentRole = roleImpersonationState.role?.role ?? 'postgres'
const hotkeySequnece: Hotkey | undefined =
SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0]
const formatKeys = hotkeySequnece ? hotkeyToKeys(hotkeySequnece) : undefined
const handleIntelliSenseChange = (checked: boolean) => {
setIntellisenseEnabled(checked)
toast.success(
`Successfully ${checked ? 'enabled' : 'disabled'} IntelliSense. ${checked ? '' : 'Please refresh your browser for changes to take place.'}`
)
}
const toggleFavorite = () => {
if (isFavorite) {
snapV2.removeFavorite(id)
toast.success('Removed from favourites')
} else {
snapV2.addFavorite(id)
toast.success('Added to favourites')
}
}
const onSelectDatabase = (databaseId: string) => {
sessionSnap.resetResults(id)
setLastSelectedDb(databaseId)
}
useEffect(() => {
const queryBar = queryBarRef.current
if (!queryBar) return
const updateQueryControlsVisibility = (width: number) => {
const shouldCollapse = width < SEPARATE_QUERY_CONTROLS_INLINE_MIN_WIDTH
setAreSeparateQueryControlsCollapsed((current) =>
current === shouldCollapse ? current : shouldCollapse
)
}
updateQueryControlsVisibility(queryBar.getBoundingClientRect().width)
const observer = new ResizeObserver(([entry]) => {
updateQueryControlsVisibility(entry.contentRect.width)
})
observer.observe(queryBar)
return () => observer.disconnect()
}, [])
const isSeparateQueryControlMode = warehouseDemoSnap.queryControlMode === 'separate'
const shouldShowCollapsedQueryControls =
isSeparateQueryControlMode && areSeparateQueryControlsCollapsed
return (
<div
ref={queryBarRef}
className="flex h-10 shrink-0 items-center justify-between border-b bg-dash-sidebar px-2 dark:bg-surface-100 @container"
>
<div className="flex min-w-0 items-center gap-x-2">
{IS_PLATFORM && <AutosaveStatus />}
{/* SavingIndicator reports auto-save progress. In manual mode the Save button owns status. */}
{IS_PLATFORM && !isManualSaveEnabled && <SavingIndicator id={id} />}
</div>
<div className="flex items-center gap-x-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="sql-editor-utility-actions"
variant="default"
icon={<MoreVertical />}
className="h-[26px] w-[26px]"
aria-label="Query options"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
{shouldShowCollapsedQueryControls && (
<>
<div className="space-y-1 px-1 py-1.5">
<p className="px-2 py-1 text-xs text-foreground-lighter">Query context</p>
<SeparateQueryControls
onSelectDatabase={onSelectDatabase}
selectedDatabaseId={lastSelectedDb}
fullWidth
/>
</div>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
Actions
</DropdownMenuLabel>
<DropdownMenuItem className="justify-between" onClick={prettifyQuery}>
<span className="flex items-center gap-x-2">
<AlignLeft size={14} strokeWidth={2} className="text-foreground-light" />
Prettify SQL
</span>
{formatKeys && <KeyboardShortcut keys={formatKeys} />}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
Preferences
</DropdownMenuLabel>
{IS_PLATFORM && (
<DropdownMenuCheckboxItem checked={isFavorite} onCheckedChange={toggleFavorite}>
Favourite query
</DropdownMenuCheckboxItem>
)}
<DropdownMenuCheckboxItem
checked={intelliSenseEnabled}
onCheckedChange={handleIntelliSenseChange}
>
IntelliSense
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
{isSeparateQueryControlMode ? (
<div className="hidden items-center gap-x-2 @4xl:flex">
<SeparateQueryControls
onSelectDatabase={onSelectDatabase}
selectedDatabaseId={lastSelectedDb}
/>
</div>
) : (
<QueryContextDropdown
currentRole={currentRole}
onSelectDatabase={onSelectDatabase}
queryTarget={queryTarget}
searchPath={searchPath}
selectedDatabaseId={lastSelectedDb}
/>
)}
<SqlEditorLimitSelector />
{isManualSaveEnabled && <SqlSaveButton id={id} />}
<SqlRunButton
hasSelection={hasSelection}
isDisabled={isDisabled || isExecuting}
isExecuting={isExecuting}
onClick={executeQuery}
/>
</div>
</div>
)
}
type QueryContextDropdownProps = {
currentRole: string
onSelectDatabase: (databaseId: string) => void
queryTarget: ReturnType<typeof useSqlQueryTarget>
searchPath: string
selectedDatabaseId: string
}
type SeparateQueryControlsProps = {
fullWidth?: boolean
onSelectDatabase: (databaseId: string) => void
selectedDatabaseId: string
}
function SeparateQueryControls({
fullWidth = false,
onSelectDatabase,
selectedDatabaseId,
}: SeparateQueryControlsProps) {
const controlClassName = fullWidth ? 'w-full [&>span]:w-full' : undefined
return (
<>
{IS_PLATFORM && (
<DatabaseSelector
selectedDatabaseId={selectedDatabaseId.length === 0 ? undefined : selectedDatabaseId}
onSelectId={onSelectDatabase}
className={controlClassName}
align="end"
/>
)}
<SqlEditorQueryTargetSelector className={controlClassName} align="end" />
<SqlEditorSearchPathSelector className={controlClassName} align="end" />
<RoleImpersonationPopover
serviceRoleLabel="postgres"
header="Run SQL query as a role"
align="end"
className={fullWidth ? 'w-full justify-start [&>span]:w-full' : undefined}
/>
</>
)
}
function QueryContextDropdown({
currentRole,
onSelectDatabase,
queryTarget,
searchPath,
selectedDatabaseId,
}: QueryContextDropdownProps) {
const { ref } = useParams()
const isPrimaryConnection = selectedDatabaseId.length === 0 || selectedDatabaseId === ref
const selectedConnectionLabel = isPrimaryConnection ? 'Primary' : 'Replica'
const activeContextLabels = [
queryTarget === 'warehouse' ? 'Warehouse' : undefined,
!isPrimaryConnection ? selectedConnectionLabel : undefined,
searchPath !== DEFAULT_SQL_EDITOR_SEARCH_PATH ? searchPath : undefined,
currentRole !== 'postgres' ? currentRole : undefined,
].filter((label): label is string => label !== undefined)
const contextSummary =
activeContextLabels.length === 0
? undefined
: activeContextLabels.length === 1
? activeContextLabels[0]
: `${activeContextLabels.length} changes`
return (
<Popover modal={false}>
<PopoverTrigger asChild>
<Button type="button" size="tiny" variant="default" className="h-[26px] justify-start pr-3">
<div className="flex min-w-0 items-center gap-1">
<span className="text-foreground-muted">Query</span>
{contextSummary !== undefined && (
<span className="hidden max-w-40 truncate text-foreground @2xl:inline">
{contextSummary}
</span>
)}
<ChevronDown className="shrink-0 text-muted" strokeWidth={1} size={12} />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end" side="bottom">
<div className="border-b px-4 py-3">
<p className="text-sm text-foreground">Query context</p>
<p className="text-xs leading-snug text-foreground-light">
These settings apply together when this query runs.
</p>
</div>
<div className="space-y-1 p-2">
{IS_PLATFORM && (
<DatabaseSelector
selectedDatabaseId={selectedDatabaseId.length === 0 ? undefined : selectedDatabaseId}
onSelectId={onSelectDatabase}
className="w-full [&>span]:w-full"
align="end"
label="Connection"
/>
)}
<SqlEditorQueryTargetSelector className="w-full [&>span]:w-full" align="end" />
<SqlEditorSearchPathSelector className="w-full [&>span]:w-full" align="end" />
<RoleImpersonationPopover
serviceRoleLabel="postgres"
header="Run SQL query as a role"
align="end"
className="w-full justify-start [&>span]:w-full"
/>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,108 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { ChevronDown } from 'lucide-react'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { isWarehouseProjectEnabled } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
export type SqlQueryTarget = 'postgres' | 'warehouse'
const QUERY_TARGET_OPTIONS: { value: SqlQueryTarget; label: string; description: string }[] = [
{
value: 'postgres',
label: 'Postgres',
description: 'Primary database connection',
},
{
value: 'warehouse',
label: 'Warehouse',
description: 'Warehouse endpoint with 1:1 schema names',
},
]
type SqlEditorQueryTargetSelectorProps = {
className?: string
align?: 'start' | 'end'
}
export function SqlEditorQueryTargetSelector({
className,
align = 'end',
}: SqlEditorQueryTargetSelectorProps) {
const { ref: projectRef } = useParams()
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
const warehouseEnabled = isWarehouseProjectEnabled(projectRef)
const [queryTarget, setQueryTarget] = useLocalStorageQuery<SqlQueryTarget>(
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
'postgres'
)
if (!isWarehouseFeatureEnabled || !warehouseEnabled) return null
const selectedOption =
QUERY_TARGET_OPTIONS.find((option) => option.value === queryTarget) ?? QUERY_TARGET_OPTIONS[0]
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="tiny"
variant="default"
className={cn('h-[26px] justify-start gap-0 pr-3', className)}
>
<div className="flex min-w-0 items-center gap-1">
<span className="text-foreground-muted">Database</span>
<span className="max-w-32 truncate text-foreground">{selectedOption.label}</span>
<ChevronDown className="shrink-0 text-muted" strokeWidth={1} size={12} />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-72" align={align}>
<DropdownMenuLabel className="flex flex-col gap-1">
<span>Query database</span>
<span className="text-xs font-normal leading-snug text-foreground-light">
Choose whether this query runs against Postgres or the Warehouse endpoint.
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={selectedOption.value}
onValueChange={(value) => setQueryTarget(value as SqlQueryTarget)}
>
{QUERY_TARGET_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<div className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
<span className="truncate text-xs text-foreground-lighter">
{option.description}
</span>
</div>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function useSqlQueryTarget(): SqlQueryTarget {
const { ref: projectRef } = useParams()
const [queryTarget] = useLocalStorageQuery<SqlQueryTarget>(
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
'postgres'
)
return queryTarget
}
@@ -0,0 +1,207 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { ChevronDown } from 'lucide-react'
import { useState } from 'react'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from 'ui'
import { useSchemasQuery } from '@/data/database/schemas-query'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
type SearchPathOption = {
value: string
label: string
description?: string
}
type SqlEditorSearchPathSelectorProps = {
className?: string
align?: 'start' | 'end'
}
const SEARCH_PATH_OPTIONS: SearchPathOption[] = [
{
value: 'public',
label: 'public',
},
{
value: 'auth',
label: 'auth',
},
{
value: 'storage',
label: 'storage',
},
]
export const DEFAULT_SQL_EDITOR_SEARCH_PATH = 'public'
const COMMON_SCHEMA_NAMES = ['public', 'auth', 'storage']
export const getSqlEditorSearchPathStorageKey = (ref: string) =>
LOCAL_STORAGE_KEYS.SQL_EDITOR_SEARCH_PATH(ref)
function getSchemaDescription(schemaName: string) {
if (schemaName.endsWith('_warehouse')) return 'Warehouse linked schema'
return undefined
}
function getSchemaSortPriority(schemaName: string) {
const commonIndex = COMMON_SCHEMA_NAMES.indexOf(schemaName)
if (commonIndex !== -1) return commonIndex
if (schemaName.endsWith('_warehouse')) return COMMON_SCHEMA_NAMES.length
return COMMON_SCHEMA_NAMES.length + 1
}
function getSearchPathOption(value: string, options: SearchPathOption[]) {
return (
options.find((option) => option.value === value) ?? {
value,
label: value,
}
)
}
export function SqlEditorSearchPathSelector({
className,
align = 'end',
}: SqlEditorSearchPathSelectorProps) {
const { ref } = useParams()
const [isShowingMoreSchemas, setIsShowingMoreSchemas] = useState(false)
const { data: project } = useSelectedProjectQuery()
const { data: schemaData } = useSchemasQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const [searchPath, setSearchPath] = useLocalStorageQuery(
getSqlEditorSearchPathStorageKey(ref ?? 'unknown'),
DEFAULT_SQL_EDITOR_SEARCH_PATH
)
const schemaOptions =
schemaData !== undefined && schemaData.length > 0
? schemaData
.map((schema) => ({
value: schema.name,
label: schema.name,
description: getSchemaDescription(schema.name),
}))
.sort((a, b) => {
const priority = getSchemaSortPriority(a.label) - getSchemaSortPriority(b.label)
return priority !== 0 ? priority : a.label.localeCompare(b.label)
})
: SEARCH_PATH_OPTIONS
const selectedOption = getSearchPathOption(searchPath, schemaOptions)
const standardSchemaOptions = schemaOptions.filter(
(option) => !option.value.endsWith('_warehouse')
)
const warehouseSchemaOptions = schemaOptions.filter((option) =>
option.value.endsWith('_warehouse')
)
const visibleStandardSchemaOptions = standardSchemaOptions.filter(
(option) =>
isShowingMoreSchemas ||
COMMON_SCHEMA_NAMES.includes(option.value) ||
option.value === selectedOption.value
)
const hiddenStandardSchemaCount =
standardSchemaOptions.length - visibleStandardSchemaOptions.length
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="tiny"
variant="default"
className={cn('h-[26px] justify-start gap-0 pr-3', className)}
>
<div className="flex min-w-0 items-center gap-1">
<span className="text-foreground-muted">Search path</span>
<span className="max-w-32 truncate text-foreground">{selectedOption.label}</span>
<ChevronDown className="shrink-0 text-muted" strokeWidth={1} size={12} />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-72" align={align}>
<DropdownMenuLabel className="flex flex-col gap-1">
<span>Search path</span>
<span className="text-xs font-normal leading-snug text-foreground-light">
Choose where your query looks first for unqualified table names.{' '}
<a
href="https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH"
target="_blank"
rel="noreferrer"
className="text-foreground underline decoration-foreground-lighter underline-offset-2 hover:decoration-foreground"
>
Learn more
</a>
</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup value={selectedOption.value} onValueChange={setSearchPath}>
{standardSchemaOptions.length > 0 && (
<>
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
{warehouseSchemaOptions.length > 0 ? 'Postgres schemas' : 'Schemas'}
</DropdownMenuLabel>
{visibleStandardSchemaOptions.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<div className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description !== undefined && (
<span className="truncate text-xs text-foreground-lighter">
{option.description}
</span>
)}
</div>
</DropdownMenuRadioItem>
))}
{hiddenStandardSchemaCount > 0 && (
<DropdownMenuItem
className="text-foreground-light"
onSelect={(event) => {
event.preventDefault()
setIsShowingMoreSchemas(true)
}}
>
Show {hiddenStandardSchemaCount} more schema
{hiddenStandardSchemaCount === 1 ? '' : 's'}
</DropdownMenuItem>
)}
</>
)}
{warehouseSchemaOptions.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-foreground-lighter">
Warehouse schemas
</DropdownMenuLabel>
{warehouseSchemaOptions.map((option) => (
<DropdownMenuRadioItem key={option.value} value={option.value}>
<div className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
{option.description !== undefined && (
<span className="truncate text-xs text-foreground-lighter">
{option.description}
</span>
)}
</div>
</DropdownMenuRadioItem>
))}
</>
)}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,126 @@
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { proxy, useSnapshot } from 'valtio'
import type { SqlQueryTarget } from './SqlEditorQueryTargetSelector'
export type SqlWarehouseHealthPreset = 'healthy' | 'catching_up' | 'degraded' | 'error'
export type SqlEditorQueryControlMode = 'compact' | 'separate'
export type SqlWarehouseResultSource =
| { type: 'query_target' }
| { type: 'search_path'; schema: string }
| { type: 'query_reference' }
const SQL_WAREHOUSE_SCHEMA_PATTERN = /\b[a-zA-Z_][\w$]*_warehouse\s*\./i
const SQL_WAREHOUSE_HEALTH_LABELS: Record<SqlWarehouseHealthPreset, string> = {
healthy: '',
catching_up: 'Catching up',
degraded: 'Degraded',
error: 'Error',
}
const SQL_WAREHOUSE_HEALTH_TOOLTIPS: Record<SqlWarehouseHealthPreset, string> = {
healthy: 'Warehouse replication is caught up with Postgres.',
catching_up:
'Warehouse replication is catching up. Recent writes may not appear in linked tables yet.',
degraded: 'Warehouse replication is degraded. Query results may be stale.',
error: 'Warehouse replication encountered an error. Check replication logs.',
}
const SQL_WAREHOUSE_HEALTH_CLASS_NAMES: Record<SqlWarehouseHealthPreset, string> = {
healthy: 'text-foreground-light hover:text-foreground',
catching_up: 'text-warning hover:text-warning-600',
degraded: 'text-destructive hover:text-destructive-600',
error: 'text-destructive hover:text-destructive-600',
}
export const sqlEditorWarehouseDemoStore = proxy<{
health: SqlWarehouseHealthPreset
queryControlMode: SqlEditorQueryControlMode
}>({
health: 'healthy',
queryControlMode: 'separate',
})
export function setSqlEditorWarehouseHealth(health: SqlWarehouseHealthPreset) {
sqlEditorWarehouseDemoStore.health = health
}
export function setSqlEditorQueryControlMode(mode: SqlEditorQueryControlMode) {
sqlEditorWarehouseDemoStore.queryControlMode = mode
}
export function resolveSqlWarehouseResultSource(
sql: string,
searchPath: string,
queryTarget: SqlQueryTarget = 'postgres'
): SqlWarehouseResultSource | undefined {
if (queryTarget === 'warehouse') {
return { type: 'query_target' }
}
if (searchPath.endsWith('_warehouse')) {
return { type: 'search_path', schema: searchPath }
}
if (SQL_WAREHOUSE_SCHEMA_PATTERN.test(sql)) {
return { type: 'query_reference' }
}
return undefined
}
function getWarehouseResultSourceTooltip(source: SqlWarehouseResultSource) {
if (source.type === 'query_target') {
return 'Results are served from the Warehouse endpoint. Schemas and table names match Postgres.'
}
if (source.type === 'search_path') {
return `Results are served from Warehouse because the search path starts with ${source.schema}.`
}
return 'Results include a linked Warehouse table referenced directly in the SQL.'
}
export function SqlWarehouseResultStatus({ source }: { source: SqlWarehouseResultSource }) {
const snap = useSnapshot(sqlEditorWarehouseDemoStore)
const health = snap.health as SqlWarehouseHealthPreset
const shouldShowHealth = health !== 'healthy'
return (
<span className="inline-flex min-w-0 items-center gap-1.5 truncate text-xs">
<Tooltip>
<TooltipTrigger asChild>
<span className="group inline-flex cursor-default text-foreground-light transition-colors hover:text-foreground">
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
Warehouse
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-72">
{getWarehouseResultSourceTooltip(source)}
</TooltipContent>
</Tooltip>
{shouldShowHealth && (
<>
<span className="shrink-0 text-foreground-muted">·</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={`group inline-flex cursor-default transition-colors ${SQL_WAREHOUSE_HEALTH_CLASS_NAMES[health]}`}
>
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
{SQL_WAREHOUSE_HEALTH_LABELS[health]}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-72">
{SQL_WAREHOUSE_HEALTH_TOOLTIPS[health]}
</TooltipContent>
</Tooltip>
</>
)}
</span>
)
}
@@ -1,70 +0,0 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { cn } from 'ui'
import { isWarehouseProjectEnabled } from '@/components/interfaces/Database/Warehouse/warehouseDemoStore'
import { useIsWarehouseEnabled } from '@/hooks/misc/useIsWarehouseEnabled'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
export type SqlQueryTarget = 'postgres' | 'warehouse'
interface SqlWarehouseTargetToggleProps {
className?: string
}
export function SqlWarehouseTargetToggle({ className }: SqlWarehouseTargetToggleProps) {
const { ref: projectRef } = useParams()
const isWarehouseFeatureEnabled = useIsWarehouseEnabled()
const warehouseEnabled = isWarehouseProjectEnabled(projectRef)
const [queryTarget, setQueryTarget] = useLocalStorageQuery<SqlQueryTarget>(
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
'postgres'
)
if (!isWarehouseFeatureEnabled || !warehouseEnabled) return null
return (
<div
className={cn(
'flex items-center rounded-md border border-overlay bg-surface-200 p-0.5 text-xs',
className
)}
role="group"
aria-label="SQL query target"
>
<button
type="button"
className={cn(
'rounded px-2 py-1 transition-colors',
queryTarget === 'postgres'
? 'bg-surface-100 text-foreground shadow-sm'
: 'text-foreground-light hover:text-foreground'
)}
onClick={() => setQueryTarget('postgres')}
>
Postgres
</button>
<button
type="button"
className={cn(
'rounded px-2 py-1 transition-colors',
queryTarget === 'warehouse'
? 'bg-surface-100 text-foreground shadow-sm'
: 'text-foreground-light hover:text-foreground'
)}
onClick={() => setQueryTarget('warehouse')}
>
Warehouse
</button>
</div>
)
}
export function useSqlQueryTarget(): SqlQueryTarget {
const { ref: projectRef } = useParams()
const [queryTarget] = useLocalStorageQuery<SqlQueryTarget>(
LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string),
'postgres'
)
return queryTarget
}
@@ -8,13 +8,8 @@ import {
} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { IS_PLATFORM } from '@/lib/constants'
import { useTrack } from '@/lib/telemetry/track'
import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
export type AutosaveStatusProps = { id: string }
export const AutosaveStatus = ({ id }: AutosaveStatusProps) => {
const snapV2 = useSqlEditorV2StateSnapshot()
export const AutosaveStatus = () => {
const track = useTrack()
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
const { selectFeaturePreview } = useFeaturePreviewModal()
@@ -27,21 +22,7 @@ export const AutosaveStatus = ({ id }: AutosaveStatusProps) => {
const sqlEditorManualSaveFlag = useFlag('sqlEditorManualSave')
const canEnableManualSave = IS_PLATFORM && sqlEditorManualSaveFlag
if (isManualSaveEnabled) {
const snippet = snapV2.snippets[id]
// A snippet only enters the store on its first edit, so a snippet that
// isn't in the store yet is a fresh, blank, untouched "new query" tab —
// there's nothing to report a save status for.
if (snippet === undefined) return null
const unsavedChanges = hasUnsavedChanges(snippet.snippet.status)
return (
<span className="text-xs text-foreground-lighter">
{unsavedChanges ? 'Unsaved edits' : 'Saved'}
</span>
)
}
if (isManualSaveEnabled) return null
return (
<div className="flex items-center gap-1">
@@ -1,5 +1,8 @@
import { Loader2 } from 'lucide-react'
import { Button, KeyboardShortcut } from 'ui'
import { CirclePlay, Loader2 } from 'lucide-react'
import { Button } from 'ui'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
interface SqlRunButtonProps {
isDisabled?: boolean
@@ -17,22 +20,27 @@ export const SqlRunButton = ({
onClick,
}: SqlRunButtonProps) => {
return (
<Button
onClick={onClick}
disabled={isDisabled}
variant="primary"
size="tiny"
data-testid="sql-run-button"
iconRight={
isExecuting ? (
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
) : (
<KeyboardShortcut keys={['Meta', 'Enter']} variant="inline" />
)
}
className={className}
<ShortcutTooltip
shortcutId={SHORTCUT_IDS.SQL_EDITOR_RUN}
label={hasSelection ? 'Run selected' : undefined}
>
{hasSelection ? 'Run selected' : 'Run'}
</Button>
<Button
onClick={onClick}
disabled={isDisabled}
variant="primary"
size="tiny"
data-testid="sql-run-button"
icon={
isExecuting ? (
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
) : (
<CirclePlay size={12} strokeWidth={1.5} />
)
}
className={className}
>
{hasSelection ? 'Run selected' : 'Run'}
</Button>
</ShortcutTooltip>
)
}
@@ -1,16 +1,17 @@
import { Loader2 } from 'lucide-react'
import { Button, KeyboardShortcut } from 'ui'
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { hasUnsavedChanges, isSaving } from '@/state/sql-editor/sql-editor-lifecycle'
import { useSqlEditorSaveCoordinator } from '@/state/sql-editor/sql-editor-save-coordinator'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
interface SqlSaveButtonProps {
id: string
className?: string
}
export const SqlSaveButton = ({ id, className }: SqlSaveButtonProps) => {
export const SqlSaveButton = ({ id }: SqlSaveButtonProps) => {
const snapV2 = useSqlEditorV2StateSnapshot()
const { requestSave } = useSqlEditorSaveCoordinator()
@@ -18,23 +19,31 @@ export const SqlSaveButton = ({ id, className }: SqlSaveButtonProps) => {
const saving = isSaving(status)
const isDirty = hasUnsavedChanges(status) && !saving
return (
const button = (
<Button
onClick={() => requestSave(id)}
disabled={!isDirty}
variant="default"
size="tiny"
data-testid="sql-save-button"
iconRight={
saving ? (
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
) : (
<KeyboardShortcut keys={['Meta', 's']} variant="inline" />
)
}
className={className}
icon={saving ? <Loader2 className="animate-spin" size={12} strokeWidth={1.5} /> : undefined}
>
Save
</Button>
)
if (isDirty) {
return <ShortcutTooltip shortcutId={SHORTCUT_IDS.SQL_EDITOR_SAVE}>{button}</ShortcutTooltip>
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">{button}</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{saving ? 'Saving changes...' : 'All changes saved'}
</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,58 @@
import { ChevronDown } from 'lucide-react'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from 'ui'
import { ROWS_PER_PAGE_OPTIONS } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
interface SqlEditorLimitSelectorProps {
variant?: 'regular' | 'connected-on-right' | 'connected-on-both'
}
export function SqlEditorLimitSelector({ variant = 'regular' }: SqlEditorLimitSelectorProps) {
const sessionSnap = useSqlEditorSessionSnapshot()
const selectedLabel =
ROWS_PER_PAGE_OPTIONS.find((opt) => opt.value === sessionSnap.limit)?.label ?? '100 rows'
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="tiny"
variant="default"
className={cn(
'h-[26px] justify-start gap-0 pr-3',
variant === 'connected-on-right' && 'rounded-r-none border-r-0',
variant === 'connected-on-both' && 'rounded-none border-x-0'
)}
>
<div className="flex items-center gap-1">
<span className="text-foreground-muted">Limit</span>
<span>{selectedLabel}</span>
<ChevronDown className="text-muted" strokeWidth={1} size={12} />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-40" align="end">
<DropdownMenuRadioGroup
value={sessionSnap.limit.toString()}
onValueChange={(val) => sessionSnap.setLimit(Number(val))}
>
{ROWS_PER_PAGE_OPTIONS.map((option) => (
<DropdownMenuRadioItem key={option.label} value={option.value.toString()}>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,50 @@
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
interface SqlResultsSummaryProps {
rowCount: number
autoLimit?: number
}
function formatRowCount(rowCount: number) {
return `${rowCount.toLocaleString()} row${rowCount === 1 ? '' : 's'}`
}
export function SqlResultsSummary({ rowCount, autoLimit }: SqlResultsSummaryProps) {
const hasReachedLimit = autoLimit !== undefined && rowCount >= autoLimit
return (
<p className="flex min-w-0 items-center gap-1.5 truncate text-xs text-foreground-lighter">
{hasReachedLimit && (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className="group inline-flex cursor-default text-warning transition-colors hover:text-warning-600">
<span className="border-b border-dotted border-current/40 transition-colors group-hover:border-current/70">
Limited
</span>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<div className="flex flex-col gap-y-1">
<span>
Results are limited to preserve browser performance when a query returns many
rows.
</span>
<span className="text-foreground-light">
Change or remove this limit from the dropdown near Run.
</span>
</div>
</TooltipContent>
</Tooltip>
<span className="shrink-0 text-foreground-muted">·</span>
</>
)}
<span className="truncate text-foreground">
{formatRowCount(rowCount)}
{hasReachedLimit && (
<span className="ml-1 text-foreground-lighter">(limit {autoLimit.toLocaleString()})</span>
)}
</span>
</p>
)
}
@@ -1,245 +0,0 @@
import { Hotkey } from '@tanstack/react-hotkeys'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react'
import { toast } from 'sonner'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
KeyboardShortcut,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { SqlWarehouseTargetToggle } from '../SqlWarehouseTargetToggle'
import { AutosaveStatus } from './AutosaveStatus'
import { SqlRunButton } from './RunButton'
import { SqlSaveButton } from './SaveButton'
import SavingIndicator from './SavingIndicator'
import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { RoleImpersonationPopover } from '@/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover'
import { DatabaseSelector } from '@/components/ui/DatabaseSelector'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { IS_PLATFORM } from '@/lib/constants'
import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut'
import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from '@/state/shortcuts/registry'
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
export type UtilityActionsProps = {
id: string
isExecuting?: boolean
isDisabled?: boolean
hasSelection?: boolean
prettifyQuery: () => void
executeQuery: () => void
}
export const UtilityActions = ({
id,
isExecuting = false,
isDisabled = false,
hasSelection = false,
prettifyQuery,
executeQuery,
}: UtilityActionsProps) => {
const { ref } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
const sessionSnap = useSqlEditorSessionSnapshot()
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true)
const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
true
)
const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string),
''
)
const snippet = snapV2.snippets[id]
const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false
const hotkeySequnece: Hotkey | undefined =
SHORTCUT_DEFINITIONS[SHORTCUT_IDS.SQL_EDITOR_FORMAT].sequence[0]
const formatKeys = hotkeySequnece ? hotkeyToKeys(hotkeySequnece) : undefined
const toggleIntellisense = () => {
setIntellisenseEnabled(!intellisenseEnabled)
toast.success(
`Successfully ${intellisenseEnabled ? 'disabled' : 'enabled'} intellisense. ${intellisenseEnabled ? 'Please refresh your browser for changes to take place.' : ''}`
)
}
const addFavorite = () => snapV2.addFavorite(id)
const removeFavorite = () => snapV2.removeFavorite(id)
const onSelectDatabase = (databaseId: string) => {
sessionSnap.resetResults(id)
setLastSelectedDb(databaseId)
}
return (
<div className="inline-flex items-center justify-end gap-x-2">
<AutosaveStatus id={id} />
{/* SavingIndicator reports auto-save progress (spinner/checkmark). In manual
mode AutosaveStatus + the Save button own the status, so hide it there. */}
{IS_PLATFORM && !isManualSaveEnabled && <SavingIndicator id={id} />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="sql-editor-utility-actions"
variant="default"
className={cn('px-1', isAiOpen ? 'block 2xl:hidden' : 'hidden')}
icon={<MoreVertical className="text-foreground-light" />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-48">
<DropdownMenuItem className="justify-between" onClick={toggleIntellisense}>
<span className="flex items-center gap-x-2">
<Keyboard size={14} className="text-foreground-light" />
Intellisense enabled
</span>
{intellisenseEnabled && <Check className="text-brand" size={16} />}
</DropdownMenuItem>
{IS_PLATFORM && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="gap-x-2"
onClick={() => {
if (isFavorite) removeFavorite()
else addFavorite()
}}
>
<Heart
size={14}
strokeWidth={2}
className={
isFavorite ? 'fill-brand stroke-none' : 'fill-none stroke-foreground-light'
}
/>
{isFavorite ? 'Remove from' : 'Add to'} favorites
</DropdownMenuItem>
</>
)}
<DropdownMenuItem className="justify-between" onClick={prettifyQuery}>
<span className="flex items-center gap-x-2">
<AlignLeft size={14} strokeWidth={2} className="text-foreground-light" />
Prettify SQL
</span>
{formatKeys && <KeyboardShortcut keys={formatKeys} />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className={cn('items-center gap-x-2', isAiOpen ? 'hidden 2xl:flex' : 'flex')}>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="text"
className="px-1"
icon={<Keyboard className="text-foreground-light" />}
aria-label="Enable Intellisense"
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Enable Intellisense</TooltipContent>
</Tooltip>
<DropdownMenuContent className="w-48">
<DropdownMenuItem className="justify-between" onClick={toggleIntellisense}>
Intellisense enabled
{intellisenseEnabled && <Check className="text-brand" size={16} />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{IS_PLATFORM && (
<Tooltip>
<TooltipTrigger asChild>
{isFavorite ? (
<Button
variant="text"
size="tiny"
onClick={removeFavorite}
className="px-1"
icon={<Heart className="fill-brand stroke-none" />}
aria-label="Remove from favorites"
/>
) : (
<Button
variant="text"
size="tiny"
onClick={addFavorite}
className="px-1"
icon={<Heart className="fill-none stroke-foreground-light" />}
aria-label="Add to favorites"
/>
)}
</TooltipTrigger>
<TooltipContent side="bottom">
{isFavorite ? 'Remove from' : 'Add to'} favorites
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
onClick={prettifyQuery}
className="px-1"
icon={<AlignLeft strokeWidth={2} className="text-foreground-light" />}
aria-label="Prettify SQL"
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="p-1 pl-2.5">
<div className="flex items-center gap-2.5">
<span>Prettify SQL</span>
{formatKeys && <KeyboardShortcut keys={formatKeys} />}
</div>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-x-2">
<SqlWarehouseTargetToggle />
<div className="flex items-center">
{IS_PLATFORM && (
<DatabaseSelector
selectedDatabaseId={lastSelectedDb.length === 0 ? undefined : lastSelectedDb}
variant="connected-on-right"
onSelectId={onSelectDatabase}
/>
)}
<RoleImpersonationPopover
serviceRoleLabel="postgres"
header="Run SQL query as a role"
variant={IS_PLATFORM ? 'connected-on-left' : 'regular'}
/>
</div>
<div className="flex items-center">
{isManualSaveEnabled && <SqlSaveButton id={id} className="rounded-r-none" />}
<SqlRunButton
hasSelection={hasSelection}
isDisabled={isDisabled || isExecuting}
isExecuting={isExecuting}
className={isManualSaveEnabled ? 'rounded-l-none' : undefined}
onClick={executeQuery}
/>
</div>
</div>
</div>
)
}
@@ -2,8 +2,9 @@ import { useParams } from 'common'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from 'ui'
import { SqlWarehouseResultStatus } from '../SqlEditorWarehouseDemo'
import { ChartConfig } from './ChartConfig'
import { UtilityActions } from './UtilityActions'
import { SqlResultsSummary } from './SqlResultsSummary'
import { UtilityTabExplain } from './UtilityTabExplain'
import { UtilityTabResults } from './UtilityTabResults'
import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton'
@@ -19,9 +20,6 @@ export type UtilityPanelProps = {
isExplainExecuting?: boolean
isDebugging?: boolean
isDisabled?: boolean
hasSelection: boolean
prettifyQuery: () => void
executeQuery: () => void
executeExplainQuery: () => void
showExplainTab?: boolean
onDebug: () => void
@@ -45,9 +43,6 @@ export const UtilityPanel = ({
isExplainExecuting,
isDebugging,
isDisabled,
hasSelection,
prettifyQuery,
executeQuery,
executeExplainQuery,
showExplainTab = true,
onDebug,
@@ -62,6 +57,11 @@ export const UtilityPanel = ({
const snippet = snapV2.snippets[id]?.snippet
const result = sessionSnap.results[id]?.[0]
const showResultsSummary =
(activeTab === 'results' || activeTab === 'chart') &&
result?.rows !== undefined &&
!isExecuting &&
!result.error
const handleTabChange = (tab: string) => {
// When switching to the explain tab, trigger the explain query
@@ -131,8 +131,8 @@ export const UtilityPanel = ({
return (
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full h-full flex flex-col">
<TabsList className="flex justify-between gap-2 px-4 overflow-x-auto min-h-[42px]">
<div className="flex items-center gap-4">
<div className="flex min-h-[42px] shrink-0 items-center justify-between gap-2 overflow-x-auto border-b pl-3 pr-2">
<TabsList className="h-auto gap-4 border-b-0 p-0">
<TabsTrigger className="py-3 text-xs" value="results">
<span className="translate-y-px">Results</span>
</TabsTrigger>
@@ -144,12 +144,23 @@ export const UtilityPanel = ({
<TabsTrigger className="py-3 text-xs" value="chart">
<span className="translate-y-px">Chart</span>
</TabsTrigger>
</TabsList>
<div className="flex min-w-0 items-center gap-x-3">
{showResultsSummary && (
<>
{result.warehouseResultSource && (
<SqlWarehouseResultStatus source={result.warehouseResultSource} />
)}
<SqlResultsSummary rowCount={result.rows.length} autoLimit={result.autoLimit} />
</>
)}
{result?.rows && (
<DownloadResultsButton
variant="text"
results={result.rows as any[]}
fileName={`Supabase Snippet ${snippet?.name ?? 'Results'}`}
align="end"
onDownloadAsCSV={() => track('sql_editor_result_download_csv_clicked')}
onCopyAsMarkdown={() => track('sql_editor_result_copy_markdown_clicked')}
onCopyAsJSON={() => track('sql_editor_result_copy_json_clicked')}
@@ -157,16 +168,7 @@ export const UtilityPanel = ({
/>
)}
</div>
<UtilityActions
id={id}
isExecuting={isExecuting}
isDisabled={isDisabled}
hasSelection={hasSelection}
prettifyQuery={prettifyQuery}
executeQuery={executeQuery}
/>
</TabsList>
</div>
<TabsContent asChild value="results" className="mt-0 grow">
<UtilityTabResults
@@ -19,6 +19,7 @@ import {
import { ButtonTooltip } from '../ui/ButtonTooltip'
import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext'
import { SqlEditorWarehouseDemoMenu } from './UserDropdown/SqlEditorWarehouseDemoMenu'
import { TimezoneDropdown } from './UserDropdown/TimezoneDropdown'
import { ProfileImage } from '@/components/ui/ProfileImage'
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
@@ -136,6 +137,7 @@ export function UserDropdown({
Changelog
</Link>
</DropdownMenuItem>
<SqlEditorWarehouseDemoMenu />
<DropdownMenuSeparator />
</DropdownMenuGroup>
</>
@@ -0,0 +1,89 @@
import { Database } from 'lucide-react'
import { toast } from 'sonner'
import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from 'ui'
import { useSnapshot } from 'valtio'
import {
setSqlEditorQueryControlMode,
setSqlEditorWarehouseHealth,
sqlEditorWarehouseDemoStore,
type SqlEditorQueryControlMode,
type SqlWarehouseHealthPreset,
} from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo'
const WAREHOUSE_HEALTH_PRESETS: { preset: SqlWarehouseHealthPreset; label: string }[] = [
{ preset: 'healthy', label: 'Live' },
{ preset: 'catching_up', label: 'Catching up' },
{ preset: 'degraded', label: 'Degraded' },
{ preset: 'error', label: 'Error' },
]
const QUERY_CONTROL_MODES: { mode: SqlEditorQueryControlMode; label: string }[] = [
{ mode: 'compact', label: 'Compact Query dropdown' },
{ mode: 'separate', label: 'Separate controls' },
]
export function SqlEditorWarehouseDemoMenu() {
const snap = useSnapshot(sqlEditorWarehouseDemoStore)
return (
<>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="flex gap-2">
<Database size={14} strokeWidth={1.5} className="text-foreground-lighter" />
SQL Warehouse demo
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
<DropdownMenuLabel className="text-xs text-foreground-lighter">
Results health
</DropdownMenuLabel>
{WAREHOUSE_HEALTH_PRESETS.map(({ preset, label }) => (
<DropdownMenuItem
key={preset}
className="cursor-pointer"
onClick={() => {
setSqlEditorWarehouseHealth(preset)
toast.message(`SQL Warehouse demo: ${label}`)
}}
>
{label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-foreground-lighter">
Query control
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={snap.queryControlMode}
onValueChange={(value) => {
if (value === 'compact' || value === 'separate') {
setSqlEditorQueryControlMode(value)
toast.message(
`SQL Warehouse demo: ${
value === 'compact' ? 'Compact Query dropdown' : 'Separate controls'
}`
)
}
}}
>
{QUERY_CONTROL_MODES.map(({ mode, label }) => (
<DropdownMenuRadioItem key={mode} value={mode} className="cursor-pointer">
{label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
</>
)
}
@@ -1,7 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useDebounce } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { FilePlus, FolderPlus, Plus, X } from 'lucide-react'
import { ChevronDown, FolderPlus, List, Plus, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
@@ -45,7 +45,6 @@ export const SQLEditorMenu = () => {
'inserted_at'
)
const appState = getAppStateSnapshot()
const debouncedSearch = useDebounce(search, 500)
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
@@ -80,95 +79,102 @@ export const SQLEditorMenu = () => {
}
}
const openRunningQueries = () => {
getAppStateSnapshot().setOnGoingQueriesPanelOpen(true)
}
useEffect(() => {
setShowSearch(debouncedSearch.length > 0)
}, [debouncedSearch])
return (
<div className="h-full flex flex-col justify-between">
<div className="flex flex-col gap-y-4 grow">
<div className="mt-4 mx-4 flex items-center justify-between gap-x-2">
<InnerSideBarFilters className="w-full p-0 gap-0">
<InnerSideBarFilterSearchInput
name="search-queries"
placeholder="Search queries..."
aria-labelledby="Search queries"
value={search}
onChange={(e) => {
const value = e.target.value
setSearch(value)
if (value.length === 0) setShowSearch(false)
}}
onKeyDown={(e) => {
if (e.code === 'Escape') {
setSearch('')
setShowSearch(false)
}
}}
>
{showSearch ? (
<Tooltip>
<TooltipTrigger
className="absolute right-1 top-[.4rem] md:top-[.3rem] transition-colors text-foreground-light hover:text-foreground"
onClick={() => {
setSearch('')
setShowSearch(false)
}}
>
<X size={18} />
</TooltipTrigger>
<TooltipContent>Clear search</TooltipContent>
</Tooltip>
) : (
<InnerSideBarFilterSortDropdown
value={sort}
onValueChange={(value: any) => setSort(value)}
>
<InnerSideBarFilterSortDropdownItem key="name" value="name">
Alphabetical
</InnerSideBarFilterSortDropdownItem>
<InnerSideBarFilterSortDropdownItem key="inserted_at" value="inserted_at">
Created At
</InnerSideBarFilterSortDropdownItem>
</InnerSideBarFilterSortDropdown>
)}
</InnerSideBarFilterSearchInput>
</InnerSideBarFilters>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
data-testid="sql-editor-new-query-button"
variant="default"
icon={<Plus className="text-foreground" />}
className="w-[26px]"
aria-label="Create a new query"
/>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Create a new query</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" side="bottom" className="w-48">
<DropdownMenuItem className="gap-x-2" onClick={() => handleNewQuery()}>
<FilePlus size={14} />
Create a new snippet
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={() => createNewFolder()}>
<FolderPlus size={14} />
Create a new folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{showSearch ? <SearchList search={debouncedSearch} /> : <SQLEditorNav sort={sort} />}
<div className="flex h-full grow flex-col gap-2 pt-5">
<div className="mx-4 flex min-w-0 shrink-0 items-center">
<Button
data-testid="sql-editor-new-query-button"
type="button"
size="tiny"
variant="default"
className="h-7 min-w-0 flex-1 justify-start rounded-r-none hover:z-10"
icon={<Plus size={14} strokeWidth={1.5} className="text-foreground-muted" />}
onClick={handleNewQuery}
>
New query
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="default"
aria-label="More query actions"
className="h-7 w-7 shrink-0 rounded-l-none -ml-px px-0"
icon={<ChevronDown size={14} strokeWidth={1.5} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="bottom" className="w-52">
<DropdownMenuItem className="gap-x-2" onClick={createNewFolder}>
<FolderPlus size={14} />
New folder
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={openRunningQueries}>
<List size={14} />
View running queries
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="p-4 border-t sticky bottom-0 bg-studio">
<Button block variant="default" onClick={() => appState.setOnGoingQueriesPanelOpen(true)}>
View running queries
</Button>
<div className="flex min-h-0 grow flex-col gap-2 pb-4">
<InnerSideBarFilters className="mx-2">
<InnerSideBarFilterSearchInput
name="search-queries"
placeholder="Search queries..."
aria-labelledby="Search queries"
value={search}
onChange={(e) => {
const value = e.target.value
setSearch(value)
if (value.length === 0) setShowSearch(false)
}}
onKeyDown={(e) => {
if (e.code === 'Escape') {
setSearch('')
setShowSearch(false)
}
}}
>
{showSearch ? (
<Tooltip>
<TooltipTrigger
className="absolute right-1 top-[.4rem] transition-colors text-foreground-light hover:text-foreground md:top-[.3rem]"
onClick={() => {
setSearch('')
setShowSearch(false)
}}
>
<X size={18} />
</TooltipTrigger>
<TooltipContent>Clear search</TooltipContent>
</Tooltip>
) : (
<InnerSideBarFilterSortDropdown
value={sort}
onValueChange={(value) => {
if (value === 'name' || value === 'inserted_at') setSort(value)
}}
>
<InnerSideBarFilterSortDropdownItem key="name" value="name">
Alphabetical
</InnerSideBarFilterSortDropdownItem>
<InnerSideBarFilterSortDropdownItem key="inserted_at" value="inserted_at">
Created At
</InnerSideBarFilterSortDropdownItem>
</InnerSideBarFilterSortDropdown>
)}
</InnerSideBarFilterSearchInput>
</InnerSideBarFilters>
{showSearch ? <SearchList search={debouncedSearch} /> : <SQLEditorNav sort={sort} />}
</div>
</div>
)
@@ -1,6 +1,5 @@
import { keepPreviousData } from '@tanstack/react-query'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common'
import { Heart } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
@@ -565,9 +564,8 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
className="mx-2 px-3"
description={
<>
Save a query to favorites for easy accessibility by clicking the{' '}
<Heart size={12} className="inline-block relative align-center -top-px" />{' '}
icon.
Save a query to favorites from the query options menu in the editor, or by
right-clicking a query in the sidebar.
</>
}
/>
+44 -25
View File
@@ -39,6 +39,7 @@ interface DatabaseSelectorProps {
className?: string
align?: 'start' | 'end'
isForm?: boolean
label?: string
}
export const DatabaseSelector = ({
@@ -50,6 +51,7 @@ export const DatabaseSelector = ({
align = 'end',
className,
isForm = false,
label = 'Source',
}: DatabaseSelectorProps) => {
const router = useRouter()
const { ref: projectRef } = useParams()
@@ -80,15 +82,25 @@ export const DatabaseSelector = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [_selectedDatabaseId])
const selectedDatabaseLabel = selectedAdditionalOption ? (
<span>{selectedAdditionalOption.name}</span>
) : (
<>
<span className="capitalize">
{isLoading || selectedDatabase?.identifier === projectRef ? 'Primary' : 'Read replica'}
</span>
{isSuccess && selectedDatabase?.identifier !== projectRef && (
<span>
({selectedDatabaseRegion} - {formattedDatabaseId})
</span>
)}
</>
)
return (
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger asChild>
<div className={cn('flex cursor-pointer', className)}>
{!isForm && (
<span className="flex items-center text-foreground-lighter px-3 rounded-lg rounded-r-none text-xs border border-button border-r-0">
Source
</span>
)}
{isForm ? (
<Button
variant="default"
icon={isLoading && <Loader2 className="animate-spin" />}
@@ -96,31 +108,38 @@ export const DatabaseSelector = ({
{...buttonProps}
className={cn(
'justify-start',
!isForm && 'rounded-l-none',
variant === 'connected-on-right' && 'rounded-r-none',
variant === 'connected-on-left' && 'rounded-l-none border-l-0',
variant === 'connected-on-both' && 'rounded-none border-x-0',
className,
buttonProps?.className
)}
>
{selectedAdditionalOption ? (
<span>{selectedAdditionalOption.name}</span>
) : (
<>
<span className="capitalize">
{isLoading || selectedDatabase?.identifier === projectRef
? 'Primary database'
: 'Read replica'}
</span>{' '}
{isSuccess && selectedDatabase?.identifier !== projectRef && (
<span>
({selectedDatabaseRegion} - {formattedDatabaseId})
</span>
)}
</>
)}
{selectedDatabaseLabel}
</Button>
</div>
) : (
<Button
size="tiny"
variant="default"
{...buttonProps}
className={cn(
'h-[26px] justify-start gap-0 pr-3',
variant === 'connected-on-right' && 'rounded-r-none border-r-0',
variant === 'connected-on-left' && 'rounded-l-none border-l-0',
variant === 'connected-on-both' && 'rounded-none border-x-0',
className,
buttonProps?.className
)}
>
<div className="flex items-center gap-1">
<span className="text-foreground-muted">{label}</span>
<span className="flex items-center gap-1">
{isLoading ? <Loader2 className="animate-spin" size={12} /> : selectedDatabaseLabel}
</span>
<ChevronDown className="text-muted" strokeWidth={1} size={12} />
</div>
</Button>
)}
</PopoverTrigger>
<PopoverContent className="p-0 w-64" side="bottom" align={align}>
<Command>
@@ -203,7 +222,7 @@ export const DatabaseSelector = ({
<div className="w-full flex items-center justify-between">
<p>
{database.identifier === projectRef
? 'Primary database'
? 'Primary'
: `Read replica (${region} - ${id})`}
</p>
{database.identifier === selectedDatabaseId && <Check size={16} />}
@@ -11,6 +11,7 @@ import {
calculateSummary,
createNodeTree,
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser'
import type { SqlWarehouseResultSource } from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo'
import { handleError as handleErrorFetchers, post } from '@/data/fetchers'
import { MB } from '@/lib/constants'
import { sqlEventParser } from '@/lib/sql-event-parser'
@@ -65,6 +66,7 @@ type ExecuteSqlVariables = {
type ExecuteSqlMutationVariables = ExecuteSqlVariables & {
autoLimit?: number
contextualInvalidation?: boolean
warehouseResultSource?: SqlWarehouseResultSource
}
/**
@@ -6,6 +6,8 @@ export const SQL_EDITOR_SHORTCUT_IDS = {
SQL_EDITOR_FORMAT: 'sql-editor.format',
SQL_EDITOR_EXPLAIN: 'sql-editor.explain',
SQL_EDITOR_NEW_SNIPPET: 'sql-editor.new-snippet',
SQL_EDITOR_RUN: 'sql-editor.run',
SQL_EDITOR_SAVE: 'sql-editor.save',
}
export type SqlEditorShortcutId =
@@ -42,4 +44,16 @@ export const sqlEditorRegistry: RegistryDefinations<SqlEditorShortcutId> = {
sequence: ['Shift+N'],
showInSettings: false,
},
[SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_RUN]: {
id: SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_RUN,
label: 'Run query',
sequence: ['Mod+Enter'],
showInSettings: false,
},
[SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_SAVE]: {
id: SQL_EDITOR_SHORTCUT_IDS.SQL_EDITOR_SAVE,
label: 'Save edits',
sequence: ['Mod+S'],
showInSettings: false,
},
}
@@ -22,6 +22,19 @@ describe('sqlEditorSessionState', () => {
expect(result?.error).toBeUndefined()
})
it('addResult stores Warehouse result metadata', () => {
sqlEditorSessionState.addResult('a', [{ x: 1 }], undefined, {
type: 'search_path',
schema: 'public_warehouse',
})
const result = sqlEditorSessionState.results['a']?.[0]
expect(result?.warehouseResultSource).toEqual({
type: 'search_path',
schema: 'public_warehouse',
})
})
it('addResultError stores the error with empty rows', () => {
sqlEditorSessionState.addResultError('a', { message: 'boom' }, 25)
@@ -1,6 +1,14 @@
import { proxy, ref, snapshot, useSnapshot } from 'valtio'
import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types'
import type { SqlWarehouseResultSource } from '@/components/interfaces/SQLEditor/SqlEditorWarehouseDemo'
export interface SqlEditorQueryResult {
rows: any[]
error?: any
autoLimit?: number
warehouseResultSource?: SqlWarehouseResultSource
}
/**
* Ephemeral, per-session SQL editor state that is NOT persisted: query results,
@@ -15,11 +23,7 @@ export const sqlEditorSessionState = proxy({
* a single query with multiple statements can return multiple results.
*/
results: {} as {
[snippetId: string]: {
rows: any[]
error?: any
autoLimit?: number
}[]
[snippetId: string]: SqlEditorQueryResult[]
},
/** EXPLAIN results, if any, keyed by snippet id. */
@@ -39,17 +43,27 @@ export const sqlEditorSessionState = proxy({
setLimit: (value: number) => (sqlEditorSessionState.limit = value),
addResult: (id: string, results: any[], autoLimit?: number) => {
addResult: (
id: string,
results: any[],
autoLimit?: number,
warehouseResultSource?: SqlWarehouseResultSource
) => {
// Use ref() to prevent Valtio from creating proxies for each row object.
// This is critical for large result sets - without ref(), Valtio wraps every
// row and nested property in a Proxy, causing massive memory overhead.
// Alright to use ref() in this case as the data is meant to be read-only and we
// don't need to track changes to the underlying data
sqlEditorSessionState.results[id] = [{ rows: ref(results), autoLimit }]
sqlEditorSessionState.results[id] = [{ rows: ref(results), autoLimit, warehouseResultSource }]
},
addResultError: (id: string, error: any, autoLimit?: number) => {
sqlEditorSessionState.results[id] = [{ rows: ref([]), error, autoLimit }]
addResultError: (
id: string,
error: any,
autoLimit?: number,
warehouseResultSource?: SqlWarehouseResultSource
) => {
sqlEditorSessionState.results[id] = [{ rows: ref([]), error, autoLimit, warehouseResultSource }]
},
resetResult: (id: string) => {
+94 -9
View File
@@ -4,26 +4,62 @@ type Investor = {
title: string
img: string
lead: boolean
scale?: number
objectPosition?: string
twitter?: string | null
linkedIn?: string | null
}
const data: Investor[] = [
// Row 1
{
name: 'GIC',
round: 'B',
title: 'GIC',
img: '/images/logos/gic--grey.png',
lead: true,
scale: 1.5,
twitter: null,
linkedIn: null,
},
{
name: 'Peak XV',
round: 'B',
title: 'Peak XV Partners',
img: '/images/logos/peakxv--grey.png',
lead: true,
scale: 0.85,
twitter: null,
linkedIn: null,
},
{
name: 'Accel',
round: 'B',
title: 'Accel',
img: '/images/logos/accel--grey.png',
lead: true,
scale: 1.12,
twitter: null,
linkedIn: null,
},
{
name: 'Craft',
round: 'B',
title: 'Craft Ventures',
img: '/images/logos/craft--grey.png',
lead: true,
scale: 0.61,
twitter: null,
linkedIn: null,
},
// Row 2
{
name: 'Coatue',
round: 'SEED',
title: 'Coatue Growth Fund',
img: '/images/logos/coatue.png',
lead: true,
twitter: null,
linkedIn: null,
},
{
name: 'Mozilla',
round: 'SEED',
title: 'Mozilla',
img: '/images/logos/mozilla--grey.png',
lead: true,
scale: 0.95,
twitter: null,
linkedIn: null,
},
@@ -36,6 +72,16 @@ const data: Investor[] = [
twitter: null,
linkedIn: null,
},
{
name: 'Stripe',
round: 'B',
title: 'Stripe',
img: '/images/logos/stripe--grey.png',
lead: true,
scale: 1.15,
twitter: null,
linkedIn: null,
},
{
name: 'Felicis',
round: 'B',
@@ -45,6 +91,45 @@ const data: Investor[] = [
twitter: null,
linkedIn: null,
},
// Row 3
{
name: 'Salesforce Ventures',
round: 'B',
title: 'Salesforce Ventures',
img: '/images/logos/salesforce--grey.png',
lead: true,
scale: 2.01,
twitter: null,
linkedIn: null,
},
{
name: 'Georgian',
round: 'B',
title: 'Georgian',
img: '/images/logos/georgian--grey.png',
lead: true,
twitter: null,
linkedIn: null,
},
{
name: 'Figma',
round: 'B',
title: 'Figma',
img: '/images/logos/figma--grey.png',
lead: true,
twitter: null,
linkedIn: null,
},
{
name: 'Mozilla',
round: 'SEED',
title: 'Mozilla',
img: '/images/logos/mozilla--grey.png',
lead: true,
scale: 0.9,
twitter: null,
linkedIn: null,
},
{
name: 'Tom Preston-Werner',
round: 'A',
+6 -12
View File
@@ -171,24 +171,18 @@ const Investors = () => {
<div className="mx-auto mt-5 mb-16 grid max-w-lg gap-0.5 lg:max-w-none lg:grid-cols-4">
{InvestorData.filter((x) => x.lead === true).map((x) => (
<div key={x.name}>
<div
className="
bg-surface-100
col-span-1 flex h-32 content-end
items-center justify-center"
>
<div className="relative h-8 w-full overflow-auto">
<div className="bg-surface-100 col-span-1 flex h-32 content-end items-center justify-center">
<div
className="relative w-full overflow-hidden"
style={{ height: `${(x.scale ?? 1) * 2}rem` }}
>
<Image
layout="fill"
src={`${x.img}`}
alt={x.name}
objectFit="scale-down"
objectPosition="center"
className="
opacity-50
contrast-0
filter
"
className="opacity-50 contrast-0 filter"
/>
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -47,6 +47,7 @@ export const LOCAL_STORAGE_KEYS = {
SQL_EDITOR_AI_SCHEMA: (ref: string) => `supabase_sql-editor-ai-schema-enabled-${ref}`,
SQL_EDITOR_AI_OPEN: 'supabase_sql-editor-ai-open',
SQL_EDITOR_LAST_SELECTED_DB: (ref: string) => `sql-editor-last-selected-db-${ref}`,
SQL_EDITOR_SEARCH_PATH: (ref: string) => `sql-editor-search-path-${ref}`,
SQL_EDITOR_QUERY_TARGET: (ref: string) => `sql-editor-query-target-${ref}`,
SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`,
SQL_EDITOR_SECTION_STATE: (ref: string) => `sql-editor-section-state-${ref}`,