diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx index 27d10d83561..724232ae8c8 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationPopover.tsx @@ -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 )} >
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts index 6d06780a2ae..2738399bd0a 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.constants.ts @@ -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' }, diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 23dc1fa3575..a4f424a68a8 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -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 ? ( +
+ +
+ ) : ( + <> + {isDiffOpen && ( +
+ { + diffEditorRef.current = editor + setIsDiffEditorMounted(true) + }} + /> + {showWidget && ( + { + 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} + /> + )} +
+ )} +
+ { + setPromptState((prev) => ({ + ...prev, + isOpen: true, + selection, + beforeSelection, + afterSelection, + startLineNumber, + endLineNumber, + })) + }} + /> + {editorRef.current && promptState.isOpen && !isDiffOpen && ( + { + 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} + /> + )} +
+ + ) + return ( <> { }} /> -
- - -
- {isLoading ? ( -
- -
- ) : ( - <> - {isDiffOpen && ( -
- { - diffEditorRef.current = editor - setIsDiffEditorMounted(true) - }} - /> - {showWidget && ( - { - 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} - /> - )} -
- )} -
- { - setPromptState((prev) => ({ - ...prev, - isOpen: true, - selection, - beforeSelection, - afterSelection, - startLineNumber, - endLineNumber, - })) - }} - /> - {editorRef.current && promptState.isOpen && !isDiffOpen && ( - { - 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} - /> - )} -
- - )} -
-
+
+ {!isLoading && ( + + )} + {showUtilityPanel ? ( + + +
{sqlEditorWorkspace}
+
- + - - {isLoading ? ( -
- -
- ) : ( + { activeTab={activeUtilityTab} onActiveTabChange={setActiveUtilityTab} /> - )} - - -
- {results?.rows !== undefined && !isExecuting && ( - -
- - -

- - {results.rows.length} row{results.rows.length > 1 ? 's' : ''} - - - {results.autoLimit !== undefined && - ` (Limited to only ${results.autoLimit} rows)`} - -

-
- -

- - Results are automatically limited to preserve browser performance, in - particular if your query returns an exceptionally large number of rows. - - - - You may change or remove this limit from the dropdown on the right - -

-
-
- {sqlQueryTarget === 'warehouse' && ( -

Served by: Warehouse

- )} -
- {results.autoLimit !== undefined && ( - - - - - - sessionSnap.setLimit(Number(val))} - > - {ROWS_PER_PAGE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - )} -
- )} -
-
+ + + ) : ( +
{sqlEditorWorkspace}
+ )}
) diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx new file mode 100644 index 00000000000..c88c63482ba --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryBar.tsx @@ -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(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 ( +
+
+ {IS_PLATFORM && } + {/* SavingIndicator reports auto-save progress. In manual mode the Save button owns status. */} + {IS_PLATFORM && !isManualSaveEnabled && } +
+ +
+ + +
+
+ ) +} + +type QueryContextDropdownProps = { + currentRole: string + onSelectDatabase: (databaseId: string) => void + queryTarget: ReturnType + 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 && ( + + )} + + + 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 ( + + + + + +
+

Query context

+

+ These settings apply together when this query runs. +

+
+
+ {IS_PLATFORM && ( + + )} + + + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryTargetSelector.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryTargetSelector.tsx new file mode 100644 index 00000000000..421f92b65b8 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorQueryTargetSelector.tsx @@ -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( + 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 ( + + + + + + + Query database + + Choose whether this query runs against Postgres or the Warehouse endpoint. + + + + setQueryTarget(value as SqlQueryTarget)} + > + {QUERY_TARGET_OPTIONS.map((option) => ( + +
+ {option.label} + + {option.description} + +
+
+ ))} +
+
+
+ ) +} + +export function useSqlQueryTarget(): SqlQueryTarget { + const { ref: projectRef } = useParams() + const [queryTarget] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string), + 'postgres' + ) + return queryTarget +} diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx new file mode 100644 index 00000000000..4f204854eda --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorSearchPathSelector.tsx @@ -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 ( + + + + + + + Search path + + Choose where your query looks first for unqualified table names.{' '} + + Learn more + + + + + + {standardSchemaOptions.length > 0 && ( + <> + + {warehouseSchemaOptions.length > 0 ? 'Postgres schemas' : 'Schemas'} + + {visibleStandardSchemaOptions.map((option) => ( + +
+ {option.label} + {option.description !== undefined && ( + + {option.description} + + )} +
+
+ ))} + {hiddenStandardSchemaCount > 0 && ( + { + event.preventDefault() + setIsShowingMoreSchemas(true) + }} + > + Show {hiddenStandardSchemaCount} more schema + {hiddenStandardSchemaCount === 1 ? '' : 's'} + + )} + + )} + {warehouseSchemaOptions.length > 0 && ( + <> + + + Warehouse schemas + + {warehouseSchemaOptions.map((option) => ( + +
+ {option.label} + {option.description !== undefined && ( + + {option.description} + + )} +
+
+ ))} + + )} +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx b/apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx new file mode 100644 index 00000000000..a2b3eed9428 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/SqlEditorWarehouseDemo.tsx @@ -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 = { + healthy: '', + catching_up: 'Catching up', + degraded: 'Degraded', + error: 'Error', +} + +const SQL_WAREHOUSE_HEALTH_TOOLTIPS: Record = { + 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 = { + 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 ( + + + + + + Warehouse + + + + + {getWarehouseResultSourceTooltip(source)} + + + {shouldShowHealth && ( + <> + · + + + + + {SQL_WAREHOUSE_HEALTH_LABELS[health]} + + + + + {SQL_WAREHOUSE_HEALTH_TOOLTIPS[health]} + + + + )} + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/SqlWarehouseTargetToggle.tsx b/apps/studio/components/interfaces/SQLEditor/SqlWarehouseTargetToggle.tsx deleted file mode 100644 index 673d5ee6df5..00000000000 --- a/apps/studio/components/interfaces/SQLEditor/SqlWarehouseTargetToggle.tsx +++ /dev/null @@ -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( - LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string), - 'postgres' - ) - - if (!isWarehouseFeatureEnabled || !warehouseEnabled) return null - - return ( -
- - -
- ) -} - -export function useSqlQueryTarget(): SqlQueryTarget { - const { ref: projectRef } = useParams() - const [queryTarget] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.SQL_EDITOR_QUERY_TARGET(projectRef as string), - 'postgres' - ) - return queryTarget -} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx index abff6c14b7a..b6603e19cfe 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx @@ -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 ( - - {unsavedChanges ? 'Unsaved edits' : 'Saved'} - - ) - } + if (isManualSaveEnabled) return null return (
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx index 861481f457d..02c901c61c7 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/RunButton.tsx @@ -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 ( - + + ) } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx index 87ce9d5e08b..4be5930ff1e 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx @@ -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 = ( ) + + if (isDirty) { + return {button} + } + + return ( + + + {button} + + + {saving ? 'Saving changes...' : 'All changes saved'} + + + ) } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlEditorLimitSelector.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlEditorLimitSelector.tsx new file mode 100644 index 00000000000..e439bb4c7ff --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlEditorLimitSelector.tsx @@ -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 ( + + + + + + sessionSnap.setLimit(Number(val))} + > + {ROWS_PER_PAGE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx new file mode 100644 index 00000000000..448fec0c6ad --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SqlResultsSummary.tsx @@ -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 ( +

+ {hasReachedLimit && ( + <> + + + + + Limited + + + + +

+ + Results are limited to preserve browser performance when a query returns many + rows. + + + Change or remove this limit from the dropdown near Run. + +
+ + + · + + )} + + {formatRowCount(rowCount)} + {hasReachedLimit && ( + (limit {autoLimit.toLocaleString()}) + )} + +

+ ) +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx deleted file mode 100644 index 8b2a507b953..00000000000 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ /dev/null @@ -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 ( -
- - {/* SavingIndicator reports auto-save progress (spinner/checkmark). In manual - mode AutosaveStatus + the Save button own the status, so hide it there. */} - {IS_PLATFORM && !isManualSaveEnabled && } - - - -
- -
- -
- {IS_PLATFORM && ( - - )} - -
- -
- {isManualSaveEnabled && } - -
-
-
- ) -} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index 125905c3b97..11edc683253 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -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 ( - -
+
+ Results @@ -144,12 +144,23 @@ export const UtilityPanel = ({ Chart + + +
+ {showResultsSummary && ( + <> + {result.warehouseResultSource && ( + + )} + + + )} {result?.rows && ( 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 = ({ /> )}
- - - +
+ diff --git a/apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx b/apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx new file mode 100644 index 00000000000..ed1c7e53a4a --- /dev/null +++ b/apps/studio/components/interfaces/UserDropdown/SqlEditorWarehouseDemoMenu.tsx @@ -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 ( + <> + + + + + SQL Warehouse demo + + + + Results health + + {WAREHOUSE_HEALTH_PRESETS.map(({ preset, label }) => ( + { + setSqlEditorWarehouseHealth(preset) + toast.message(`SQL Warehouse demo: ${label}`) + }} + > + {label} + + ))} + + + Query control + + { + 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 }) => ( + + {label} + + ))} + + + + + ) +} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 2dc29b01f0d..e2c04267d3d 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -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 ( -
-
-
- - { - const value = e.target.value - setSearch(value) - if (value.length === 0) setShowSearch(false) - }} - onKeyDown={(e) => { - if (e.code === 'Escape') { - setSearch('') - setShowSearch(false) - } - }} - > - {showSearch ? ( - - { - setSearch('') - setShowSearch(false) - }} - > - - - Clear search - - ) : ( - setSort(value)} - > - - Alphabetical - - - Created At - - - )} - - - - - - -
- - {showSearch ? : } +
+
+ + + +
-
- +
+ + { + const value = e.target.value + setSearch(value) + if (value.length === 0) setShowSearch(false) + }} + onKeyDown={(e) => { + if (e.code === 'Escape') { + setSearch('') + setShowSearch(false) + } + }} + > + {showSearch ? ( + + { + setSearch('') + setShowSearch(false) + }} + > + + + Clear search + + ) : ( + { + if (value === 'name' || value === 'inserted_at') setSort(value) + }} + > + + Alphabetical + + + Created At + + + )} + + + + {showSearch ? : }
) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 9a536480ccb..6530e9eca16 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -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{' '} - {' '} - icon. + Save a query to favorites from the query options menu in the editor, or by + right-clicking a query in the sidebar. } /> diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index 0543f8f87dd..a810e324268 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -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 ? ( + {selectedAdditionalOption.name} + ) : ( + <> + + {isLoading || selectedDatabase?.identifier === projectRef ? 'Primary' : 'Read replica'} + + {isSuccess && selectedDatabase?.identifier !== projectRef && ( + + ({selectedDatabaseRegion} - {formattedDatabaseId}) + + )} + + ) + return ( -
- {!isForm && ( - - Source - - )} + {isForm ? ( -
+ ) : ( + + )}
@@ -203,7 +222,7 @@ export const DatabaseSelector = ({

{database.identifier === projectRef - ? 'Primary database' + ? 'Primary' : `Read replica (${region} - ${id})`}

{database.identifier === selectedDatabaseId && } diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts index 3718fac1f59..e8861d01686 100644 --- a/apps/studio/data/sql/execute-sql-mutation.ts +++ b/apps/studio/data/sql/execute-sql-mutation.ts @@ -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 } /** diff --git a/apps/studio/state/shortcuts/registry/sql-editor.ts b/apps/studio/state/shortcuts/registry/sql-editor.ts index c2d91122ed9..24bb0863f74 100644 --- a/apps/studio/state/shortcuts/registry/sql-editor.ts +++ b/apps/studio/state/shortcuts/registry/sql-editor.ts @@ -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 = { 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, + }, } diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.test.ts b/apps/studio/state/sql-editor/sql-editor-session-state.test.ts index 46768b2a979..7c5353d3d0d 100644 --- a/apps/studio/state/sql-editor/sql-editor-session-state.test.ts +++ b/apps/studio/state/sql-editor/sql-editor-session-state.test.ts @@ -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) diff --git a/apps/studio/state/sql-editor/sql-editor-session-state.ts b/apps/studio/state/sql-editor/sql-editor-session-state.ts index 8cb9618c4fa..46173d49d08 100644 --- a/apps/studio/state/sql-editor/sql-editor-session-state.ts +++ b/apps/studio/state/sql-editor/sql-editor-session-state.ts @@ -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) => { diff --git a/apps/www/data/Investors.tsx b/apps/www/data/Investors.tsx index a149f1efb4a..ed1adf033e6 100644 --- a/apps/www/data/Investors.tsx +++ b/apps/www/data/Investors.tsx @@ -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', diff --git a/apps/www/pages/company.tsx b/apps/www/pages/company.tsx index 544f0813e95..f73c19d9aa9 100644 --- a/apps/www/pages/company.tsx +++ b/apps/www/pages/company.tsx @@ -171,24 +171,18 @@ const Investors = () => {
{InvestorData.filter((x) => x.lead === true).map((x) => (
-
-
+
+
{x.name}
diff --git a/apps/www/public/images/logos/accel--grey.png b/apps/www/public/images/logos/accel--grey.png new file mode 100644 index 00000000000..f0590898664 Binary files /dev/null and b/apps/www/public/images/logos/accel--grey.png differ diff --git a/apps/www/public/images/logos/craft--grey.png b/apps/www/public/images/logos/craft--grey.png new file mode 100644 index 00000000000..a8e2061077d Binary files /dev/null and b/apps/www/public/images/logos/craft--grey.png differ diff --git a/apps/www/public/images/logos/figma--grey.png b/apps/www/public/images/logos/figma--grey.png new file mode 100644 index 00000000000..1fddee5c818 Binary files /dev/null and b/apps/www/public/images/logos/figma--grey.png differ diff --git a/apps/www/public/images/logos/georgian--grey.png b/apps/www/public/images/logos/georgian--grey.png new file mode 100644 index 00000000000..a305e592939 Binary files /dev/null and b/apps/www/public/images/logos/georgian--grey.png differ diff --git a/apps/www/public/images/logos/gic--grey.png b/apps/www/public/images/logos/gic--grey.png new file mode 100644 index 00000000000..00343538e11 Binary files /dev/null and b/apps/www/public/images/logos/gic--grey.png differ diff --git a/apps/www/public/images/logos/peakxv--grey.png b/apps/www/public/images/logos/peakxv--grey.png new file mode 100644 index 00000000000..fdb8969dd16 Binary files /dev/null and b/apps/www/public/images/logos/peakxv--grey.png differ diff --git a/apps/www/public/images/logos/salesforce--grey.png b/apps/www/public/images/logos/salesforce--grey.png new file mode 100644 index 00000000000..a190ef879b8 Binary files /dev/null and b/apps/www/public/images/logos/salesforce--grey.png differ diff --git a/apps/www/public/images/logos/stripe--grey.png b/apps/www/public/images/logos/stripe--grey.png new file mode 100644 index 00000000000..d0fc63c4cc4 Binary files /dev/null and b/apps/www/public/images/logos/stripe--grey.png differ diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 833846a82ba..4f061550821 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -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}`,