mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
feat(studio): SQL Editor IA improvements
Refactor the SQL Editor query bar, results panel, and sidebar chrome for clearer run controls, post-run metadata, and consolidated query actions. Includes warehouse routing/lag display in results for prototype demos.
This commit is contained in:
1 parent
963ab3e63b
commit
302abc5e27
12 files changed
+594
-629
No files matched your search
@@ -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 { useCallback, useEffect, 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,6 +41,7 @@ import {
|
||||
isUpdateWithoutWhere,
|
||||
suffixWithLimit,
|
||||
} from './SQLEditor.utils'
|
||||
import { SqlEditorQueryBar } from './SqlEditorQueryBar'
|
||||
import { useAddDefinitions } from './useAddDefinitions'
|
||||
import { UtilityPanel } from './UtilityPanel/UtilityPanel'
|
||||
import {
|
||||
@@ -93,7 +79,6 @@ import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
||||
import { useShortcut } from '@/state/shortcuts/useShortcut'
|
||||
import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
|
||||
import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
import { useSqlEditorDiffRequestSnapshot } from '@/state/sql-editor/sql-editor-diff-request'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-state'
|
||||
import { createTabId, useTabsStateSnapshot } from '@/state/tabs'
|
||||
|
||||
@@ -122,7 +107,6 @@ export const SQLEditor = () => {
|
||||
const { openSidebar } = useSidebarManagerSnapshot()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
const sessionSnap = useSqlEditorSessionSnapshot()
|
||||
const diffRequest = useSqlEditorDiffRequestSnapshot()
|
||||
const getImpersonatedRoleState = useGetImpersonatedRoleState()
|
||||
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
|
||||
const { aiOptInLevel } = useOrgAiOptInLevel()
|
||||
@@ -156,9 +140,6 @@ export const SQLEditor = () => {
|
||||
const [potentialIssues, setPotentialIssues] = useState<PotentialIssues>()
|
||||
|
||||
const [showWidget, setShowWidget] = useState(false)
|
||||
// Bumped on every editor mount (including the keyed remount on snippet switch)
|
||||
// so a diff request that arrived before the editor was ready gets re-processed.
|
||||
const [editorMountCount, setEditorMountCount] = useState(0)
|
||||
const [activeUtilityTab, setActiveUtilityTab] = useState<string>('results')
|
||||
|
||||
const refocusEditor = useCallback(() => {
|
||||
@@ -205,7 +186,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
|
||||
)
|
||||
@@ -544,7 +524,7 @@ export const SQLEditor = () => {
|
||||
databaseSelectorState.selectedDatabaseId,
|
||||
databases,
|
||||
lineHighlights,
|
||||
sessionSnap,
|
||||
snapV2,
|
||||
])
|
||||
|
||||
useShortcut(SHORTCUT_IDS.SQL_EDITOR_EXPLAIN, executeExplainQuery, {
|
||||
@@ -577,8 +557,6 @@ export const SQLEditor = () => {
|
||||
)
|
||||
|
||||
const onMount = (editor: IStandaloneCodeEditor) => {
|
||||
setEditorMountCount((count) => count + 1)
|
||||
|
||||
const tabId = createTabId('sql', { id })
|
||||
const tabData = tabs.tabsMap[tabId]
|
||||
|
||||
@@ -834,40 +812,30 @@ export const SQLEditor = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSuccessReadReplicas, databases, ref])
|
||||
|
||||
const drainDiffRequest = useEffectEvent(() => {
|
||||
const request = diffRequest.pending
|
||||
if (request === undefined) return
|
||||
|
||||
const editorModel = editorRef.current?.getModel()
|
||||
// Editor isn't ready yet; leave the request pending. editorMountCount bumps
|
||||
// on mount and re-runs this effect, so the request applies once mounted.
|
||||
if (!editorModel) return
|
||||
|
||||
const { diffType, sql } = request
|
||||
const existingValue = editorRef.current?.getValue() ?? ''
|
||||
if (existingValue.length === 0) {
|
||||
// if the editor is empty, just copy over the code
|
||||
editorRef.current?.executeEdits('apply-ai-message', [
|
||||
{
|
||||
text: `${sql}`,
|
||||
range: editorModel.getFullModelRange(),
|
||||
},
|
||||
])
|
||||
} else {
|
||||
const currentSql = editorRef.current?.getValue()
|
||||
const diff = { original: currentSql || '', modified: sql }
|
||||
setSourceSqlDiff(diff)
|
||||
setSelectedDiffType(diffType)
|
||||
}
|
||||
|
||||
// One-shot: drain the request so it can't re-apply to a later editor or session.
|
||||
diffRequest.consumeDiffRequest()
|
||||
})
|
||||
useEffect(() => {
|
||||
drainDiffRequest()
|
||||
// until we can upgrade eslint to ignore useEffectEvent
|
||||
if (snapV2.diffContent !== undefined) {
|
||||
const { diffType, sql }: { diffType: DiffType; sql: string } = snapV2.diffContent
|
||||
const editorModel = editorRef.current?.getModel()
|
||||
if (!editorModel) return
|
||||
|
||||
const existingValue = editorRef.current?.getValue() ?? ''
|
||||
if (existingValue.length === 0) {
|
||||
// if the editor is empty, just copy over the code
|
||||
editorRef.current?.executeEdits('apply-ai-message', [
|
||||
{
|
||||
text: `${sql}`,
|
||||
range: editorModel.getFullModelRange(),
|
||||
},
|
||||
])
|
||||
} else {
|
||||
const currentSql = editorRef.current?.getValue()
|
||||
const diff = { original: currentSql || '', modified: sql }
|
||||
setSourceSqlDiff(diff)
|
||||
setSelectedDiffType(diffType)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [diffRequest.pending, editorMountCount])
|
||||
}, [snapV2.diffContent])
|
||||
|
||||
// We want to check if the diff editor is mounted and if it is, we want to show the widget
|
||||
// We also want to cleanup the widget when the diff editor is closed
|
||||
@@ -881,6 +849,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
|
||||
@@ -914,140 +997,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}
|
||||
@@ -1055,66 +1033,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">
|
||||
<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>
|
||||
{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,145 @@
|
||||
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,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
KeyboardShortcut,
|
||||
} from 'ui'
|
||||
|
||||
import { SqlRunButton } from './UtilityPanel/RunButton'
|
||||
import SavingIndicator from './UtilityPanel/SavingIndicator'
|
||||
import { SqlEditorLimitSelector } from './UtilityPanel/SqlEditorLimitSelector'
|
||||
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 { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
|
||||
export type SqlEditorQueryBarProps = {
|
||||
id: string
|
||||
isExecuting?: boolean
|
||||
isDisabled?: boolean
|
||||
hasSelection?: boolean
|
||||
prettifyQuery: () => void
|
||||
executeQuery: () => void
|
||||
}
|
||||
|
||||
export function SqlEditorQueryBar({
|
||||
id,
|
||||
isExecuting = false,
|
||||
isDisabled = false,
|
||||
hasSelection = false,
|
||||
prettifyQuery,
|
||||
executeQuery,
|
||||
}: SqlEditorQueryBarProps) {
|
||||
const { ref } = useParams()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
|
||||
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 toggleFavorite = () => {
|
||||
if (isFavorite) snapV2.removeFavorite(id)
|
||||
else snapV2.addFavorite(id)
|
||||
}
|
||||
|
||||
const onSelectDatabase = (databaseId: string) => {
|
||||
snapV2.resetResults(id)
|
||||
setLastSelectedDb(databaseId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b bg-dash-sidebar px-2 dark:bg-surface-100">
|
||||
<div className="flex min-w-0 items-center">{IS_PLATFORM && <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-52">
|
||||
<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>
|
||||
{IS_PLATFORM && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-x-2" onClick={toggleFavorite}>
|
||||
<Heart
|
||||
size={14}
|
||||
strokeWidth={2}
|
||||
className={
|
||||
isFavorite ? 'fill-brand stroke-none' : 'fill-none stroke-foreground-light'
|
||||
}
|
||||
/>
|
||||
{isFavorite ? 'Remove from' : 'Add to'} favorites
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{IS_PLATFORM && (
|
||||
<DatabaseSelector
|
||||
selectedDatabaseId={lastSelectedDb.length === 0 ? undefined : lastSelectedDb}
|
||||
onSelectId={onSelectDatabase}
|
||||
/>
|
||||
)}
|
||||
<RoleImpersonationPopover serviceRoleLabel="postgres" header="Run SQL query as a role" />
|
||||
<SqlEditorLimitSelector />
|
||||
<SqlRunButton
|
||||
hasSelection={hasSelection}
|
||||
isDisabled={isDisabled || isExecuting}
|
||||
isExecuting={isExecuting}
|
||||
onClick={executeQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,39 @@
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
interface SqlResultsSummaryProps {
|
||||
rowCount: number
|
||||
autoLimit?: number
|
||||
}
|
||||
|
||||
export function SqlResultsSummary({ rowCount, autoLimit }: SqlResultsSummaryProps) {
|
||||
return (
|
||||
<p className="flex min-w-0 items-center truncate text-xs text-foreground-lighter">
|
||||
{autoLimit !== undefined ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate text-foreground">
|
||||
{rowCount} row{rowCount > 1 ? 's' : ''}
|
||||
<span className="text-foreground-lighter ml-1">
|
||||
{` (Limited to only ${autoLimit} rows)`}
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p 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>
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="truncate text-foreground">
|
||||
{rowCount} row{rowCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -1,232 +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 { SqlRunButton } from './RunButton'
|
||||
import SavingIndicator from './SavingIndicator'
|
||||
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 { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
|
||||
import { useSqlEditorSessionSnapshot } from '@/state/sql-editor/sql-editor-session-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 [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">
|
||||
{IS_PLATFORM && <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 justify-between gap-x-2">
|
||||
<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-both' : 'connected-on-right'}
|
||||
/>
|
||||
<SqlRunButton
|
||||
hasSelection={hasSelection}
|
||||
isDisabled={isDisabled || isExecuting}
|
||||
isExecuting={isExecuting}
|
||||
className="rounded-l-none"
|
||||
onClick={executeQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { toast } from 'sonner'
|
||||
import { Tabs_Shadcn_, TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui'
|
||||
|
||||
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 +19,6 @@ export type UtilityPanelProps = {
|
||||
isExplainExecuting?: boolean
|
||||
isDebugging?: boolean
|
||||
isDisabled?: boolean
|
||||
hasSelection: boolean
|
||||
prettifyQuery: () => void
|
||||
executeQuery: () => void
|
||||
executeExplainQuery: () => void
|
||||
showExplainTab?: boolean
|
||||
onDebug: () => void
|
||||
@@ -45,9 +42,6 @@ export const UtilityPanel = ({
|
||||
isExplainExecuting,
|
||||
isDebugging,
|
||||
isDisabled,
|
||||
hasSelection,
|
||||
prettifyQuery,
|
||||
executeQuery,
|
||||
executeExplainQuery,
|
||||
showExplainTab = true,
|
||||
onDebug,
|
||||
@@ -62,6 +56,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
|
||||
@@ -135,8 +134,8 @@ export const UtilityPanel = ({
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full h-full flex flex-col"
|
||||
>
|
||||
<TabsList_Shadcn_ 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_Shadcn_ className="h-auto gap-4 border-b-0 p-0">
|
||||
<TabsTrigger_Shadcn_ className="py-3 text-xs" value="results">
|
||||
<span className="translate-y-px">Results</span>
|
||||
</TabsTrigger_Shadcn_>
|
||||
@@ -148,12 +147,18 @@ export const UtilityPanel = ({
|
||||
<TabsTrigger_Shadcn_ className="py-3 text-xs" value="chart">
|
||||
<span className="translate-y-px">Chart</span>
|
||||
</TabsTrigger_Shadcn_>
|
||||
</TabsList_Shadcn_>
|
||||
|
||||
<div className="flex min-w-0 items-center gap-x-3">
|
||||
{showResultsSummary && (
|
||||
<SqlResultsSummary rowCount={result.rows.length} autoLimit={result.autoLimit} />
|
||||
)}
|
||||
|
||||
{result?.rows && (
|
||||
<DownloadResultsButton
|
||||
variant="text"
|
||||
results={result.rows as any[]}
|
||||
fileName={`Supabase Snippet ${snippet.name}`}
|
||||
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')}
|
||||
@@ -161,16 +166,7 @@ export const UtilityPanel = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UtilityActions
|
||||
id={id}
|
||||
isExecuting={isExecuting}
|
||||
isDisabled={isDisabled}
|
||||
hasSelection={hasSelection}
|
||||
prettifyQuery={prettifyQuery}
|
||||
executeQuery={executeQuery}
|
||||
/>
|
||||
</TabsList_Shadcn_>
|
||||
</div>
|
||||
|
||||
<TabsContent_Shadcn_ asChild value="results" className="mt-0 grow">
|
||||
<UtilityTabResults
|
||||
|
||||
@@ -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,100 @@ 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: 'name' | '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'
|
||||
@@ -559,9 +558,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.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -80,15 +80,27 @@ 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 database'
|
||||
: '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">Source</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>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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',
|
||||
}
|
||||
|
||||
export type SqlEditorShortcutId =
|
||||
@@ -42,4 +43,10 @@ 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,
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user