feat(sql-editor): add manual save feature preview (#47745)

## What

Adds an opt-in **SQL Editor manual save** feature preview that switches
the SQL Editor from autosaving every edit to saving only on demand, and
hardens the tab-close flow so unsaved edits are handled correctly.

## Changes

**Feature preview**
- New `sqlEditorManualSave` flag + `UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE`
local-storage toggle, wired into the Feature Preview modal with an
explanatory panel.
- `useIsSqlEditorManualSaveEnabled` gates behavior on both the flag and
the user's preview opt-in.

**Editor toolbar**
- Save button (with `Cmd+S`) next to Run, plus an autosave status
indicator showing dirty/saving/saved state and a shortcut to disable
autosave (emits a `sql_editor_autosave_disable_clicked` telemetry
event).

**Discard on close**
- Closing a snippet tab with unsaved edits prompts for confirmation and,
on confirm, actually discards the local edits and evicts the cached
server copy so the snippet reopens clean.

**Decouple tab layout from SQL specifics**
- Tabs store gains a generic per-type close-handler registry
(`registerTabCloseHandler` / `getCloseConfirmation` / `closeTabs`). The
SQL editor registers its discard + confirmation behavior from the save
coordinator.
- Low-level `removeTab`/`removeTabs` (rename/move re-keying, stale
cleanup) intentionally do **not** trigger discard.
- Adds `statusOnDiscard` lifecycle transition and `clearSnippetContent`
store action.

## Testing
- `pnpm --filter=studio typecheck` — clean.
- Added unit tests for the close-handler registry (fires on single/multi
close, skips re-keying/cleanup removals, respects tab type, selects
confirmation copy, unregisters cleanly).

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

* **New Features**
* Added a SQL editor manual-save preview with a “Save” button and
`Cmd+S`, plus a modal option to disable manual-save/preview.
* Added “unsaved changes” tab status indication when manual-save is
enabled.
* Introduced tab-type-specific close confirmations (shown only when
needed).
* **Bug Fixes**
* In manual-save mode, closing a SQL tab with unsaved edits now clears
local snippet content and refreshes it on reopen.
* **Tests**
  * Added coverage for tab close handlers and confirmation behavior.
* **Chores**
* Added a persisted setting allowlist entry and tracked autosave-disable
clicks via telemetry.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-07-09 08:37:38 -04:00
1 parent a3f2c4ffc1
commit 1987f19d0a
17 files changed
+777 -157

No files matched your search

@@ -127,6 +127,12 @@ export const useIsJitDbAccessEnabled = () => {
return jitDbAccessEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]
}
export const useIsSqlEditorManualSaveEnabled = () => {
const { flags } = useFeaturePreviewContext()
const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave')
return sqlEditorManualSaveEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]
}
export const useIsRLSTesterEnabled = () => {
const { flags } = useFeaturePreviewContext()
return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER]
@@ -35,6 +35,7 @@ import { JitDbAccessPreview } from './JitDbAccessPreview'
import { PgDeltaDiffPreview } from './PgDeltaDiffPreview'
import { PlatformWebhooksPreview } from './PlatformWebhooksPreview'
import { RLSTesterPreview } from './RLSTesterPreview'
import { SqlEditorManualSavePreview } from './SqlEditorManualSavePreview'
import { UnifiedLogsPreview } from './UnifiedLogsPreview'
import { FeaturePreview, useFeaturePreviews } from './useFeaturePreviews'
import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
@@ -52,6 +53,7 @@ const FEATURE_PREVIEW_KEY_TO_CONTENT: {
[LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS]: <PlatformWebhooksPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: <JitDbAccessPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER]: <RLSTesterPreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: <SqlEditorManualSavePreview />,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: <IntegrationsLayoutPreview />,
}
@@ -0,0 +1,17 @@
export const SqlEditorManualSavePreview = () => {
return (
<div className="space-y-4">
<p className="text-sm text-foreground-light">
Switch the SQL Editor from autosaving every edit to saving only when you ask it to.
</p>
<div className="space-y-2">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>Stop auto-saving snippet edits as you type</li>
<li>Add a Save button next to Run in the SQL Editor toolbar</li>
<li>Let you save with Cmd+S at any time</li>
</ul>
</div>
</div>
)
}
@@ -23,6 +23,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
const platformWebhooksEnabled = useFlag('platformWebhooks')
const jitDbAccessEnabled = useFlag('jitDbAccess')
const isMarketplaceEnabled = useFlag('marketplaceIntegrations')
const sqlEditorManualSaveEnabled = useFlag('sqlEditorManualSave')
const unifiedLogsDefaultOptIn = useFlag('unifiedLogsDefaultOptIn')
@@ -109,7 +110,22 @@ export const useFeaturePreviews = (): FeaturePreview[] => {
isDefaultOptIn: false,
getRoute: (ref?: string) => `/project/${ref}/integrations`,
},
{
key: LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE,
name: 'Disable snippet auto-saving',
discussionsUrl: undefined,
isNew: true,
isPlatformOnly: true,
isDefaultOptIn: false,
enabled: sqlEditorManualSaveEnabled,
},
].sort((a, b) => Number(b.isNew) - Number(a.isNew)),
[unifiedLogsDefaultOptIn, platformWebhooksEnabled, jitDbAccessEnabled, isMarketplaceEnabled]
[
unifiedLogsDefaultOptIn,
platformWebhooksEnabled,
jitDbAccessEnabled,
isMarketplaceEnabled,
sqlEditorManualSaveEnabled,
]
)
}
@@ -0,0 +1,33 @@
import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { hasUnsavedChanges } from '@/state/sql-editor/sql-editor-lifecycle'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
import type { Tab } from '@/state/tabs'
/** The snippet id a SQL tab represents. Prefer the metadata; fall back to the id scheme. */
export function getSnippetIdFromTab(tab: Tab): string {
return tab.metadata?.sqlId ?? tab.id.replace(/^sql-/, '')
}
/**
* VS Code-style unsaved-changes dot for a SQL snippet tab. Renders only in
* manual-save mode when the snippet has unsaved edits — in auto mode edits
* persist on their own, so a dot would just flicker during the debounce.
*
* Registered as the SQL tab type's status indicator (see the save coordinator)
* so the tabs layout can render it without knowing anything about snippets.
*/
export const SqlTabStatusIndicator = ({ tab }: { tab: Tab }) => {
const snapV2 = useSqlEditorV2StateSnapshot()
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
const status = snapV2.snippets[getSnippetIdFromTab(tab)]?.snippet.status
if (!isManualSaveEnabled || !hasUnsavedChanges(status)) return null
return (
<span
role="img"
aria-label="Unsaved changes"
className="block size-2 shrink-0 rounded-full bg-warning"
/>
)
}
@@ -0,0 +1,70 @@
import { LOCAL_STORAGE_KEYS, useFlag } from 'common'
import { PowerOff } from 'lucide-react'
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import {
useFeaturePreviewModal,
useIsSqlEditorManualSaveEnabled,
} 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()
const track = useTrack()
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
const { selectFeaturePreview } = useFeaturePreviewModal()
// Whether the manual-save preview can actually be opted into. Mirrors the
// feature preview modal's own filter (platform-only + ConfigCat flag), so we
// don't offer to "disable autosave" when there's no preview to switch to.
// `isManualSaveEnabled` also being false for self-hosted / flag-off users is
// why it can't gate this affordance.
const sqlEditorManualSaveFlag = useFlag('sqlEditorManualSave')
const canEnableManualSave = IS_PLATFORM && sqlEditorManualSaveFlag
if (isManualSaveEnabled) {
const snippet = snapV2.snippets[id]
// A snippet only enters the store on its first edit, so a snippet that
// isn't in the store yet is a fresh, blank, untouched "new query" tab —
// there's nothing to report a save status for.
if (snippet === undefined) return null
const unsavedChanges = hasUnsavedChanges(snippet.snippet.status)
return (
<span className="text-xs text-foreground-lighter">
{unsavedChanges ? 'Unsaved edits' : 'Saved'}
</span>
)
}
return (
<div className="flex items-center gap-1">
<span className="text-xs text-foreground-lighter">Autosave enabled</span>
{canEnableManualSave && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="text"
size="tiny"
className="px-1"
aria-label="Disable autosave"
icon={<PowerOff size={14} className="text-foreground-light" />}
onClick={() => {
track('sql_editor_autosave_disable_clicked')
selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE)
}}
/>
</TooltipTrigger>
<TooltipContent side="bottom">Disable autosave (feature preview)</TooltipContent>
</Tooltip>
)}
</div>
)
}
@@ -0,0 +1,40 @@
import { Loader2 } from 'lucide-react'
import { Button, KeyboardShortcut } from 'ui'
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) => {
const snapV2 = useSqlEditorV2StateSnapshot()
const { requestSave } = useSqlEditorSaveCoordinator()
const status = snapV2.snippets[id]?.snippet.status
const saving = isSaving(status)
const isDirty = hasUnsavedChanges(status) && !saving
return (
<Button
onClick={() => requestSave(id)}
disabled={!isDirty}
variant="default"
size="tiny"
data-testid="sql-save-button"
iconRight={
saving ? (
<Loader2 className="animate-spin" size={10} strokeWidth={1.5} />
) : (
<KeyboardShortcut keys={['Meta', 's']} variant="inline" />
)
}
className={className}
>
Save
</Button>
)
}
@@ -16,8 +16,11 @@ import {
TooltipTrigger,
} from 'ui'
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'
@@ -47,6 +50,7 @@ export const UtilityActions = ({
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(
@@ -83,7 +87,10 @@ export const UtilityActions = ({
return (
<div className="inline-flex items-center justify-end gap-x-2">
{IS_PLATFORM && <SavingIndicator id={id} />}
<AutosaveStatus id={id} />
{/* SavingIndicator reports auto-save progress (spinner/checkmark). In manual
mode AutosaveStatus + the Save button own the status, so hide it there. */}
{IS_PLATFORM && !isManualSaveEnabled && <SavingIndicator id={id} />}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -204,7 +211,7 @@ export const UtilityActions = ({
</Tooltip>
</div>
<div className="flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-2">
<div className="flex items-center">
{IS_PLATFORM && (
<DatabaseSelector
@@ -216,13 +223,17 @@ export const UtilityActions = ({
<RoleImpersonationPopover
serviceRoleLabel="postgres"
header="Run SQL query as a role"
variant={IS_PLATFORM ? 'connected-on-both' : 'connected-on-right'}
variant={IS_PLATFORM ? 'connected-on-left' : 'regular'}
/>
</div>
<div className="flex items-center">
{isManualSaveEnabled && <SqlSaveButton id={id} className="rounded-r-none" />}
<SqlRunButton
hasSelection={hasSelection}
isDisabled={isDisabled || isExecuting}
isExecuting={isExecuting}
className="rounded-l-none"
className={isManualSaveEnabled ? 'rounded-l-none' : undefined}
onClick={executeQuery}
/>
</div>
@@ -31,6 +31,11 @@ export const SortableTab = ({
}) => {
const editor = useEditorType()
const tabs = useTabsStateSnapshot()
// Reading the registration version subscribes this tab to handler (un)registers,
// so an indicator registered after first paint (handlers register in an effect)
// is still picked up. The layout stays agnostic of what the indicator shows.
void tabs.handlerRegistrationVersion
const StatusIndicator = tabs.getTabStatusIndicator(tab.type)
const { selectedSchema: currentSchema } = useQuerySchemaState()
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id,
@@ -101,25 +106,35 @@ export const SortableTab = ({
</AnimatePresence>
<span>{tab.label || 'Untitled'}</span>
</div>
<span
role="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
className="p-0.5 ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
onClose(tab.id)
}}
>
<X size={12} className="text-foreground-light" />
</span>
{/* VS Code-style slot: the type's status indicator (e.g. an unsaved dot)
shows at rest and swaps to the close button on hover. */}
<div className="relative ml-1 flex size-5 items-center justify-center">
{StatusIndicator && (
<span className="absolute inset-0 flex items-center justify-center group-hover:opacity-0">
<StatusIndicator tab={tab} />
</span>
)}
<span
role="button"
aria-label="Close tab"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
onClose(tab.id)
}}
>
<X size={12} className="text-foreground-light" />
</span>
</div>
<div className="absolute w-full top-0 left-0 right-0 h-px bg-foreground opacity-0 group-data-[state=active]:opacity-100" />
</TabsTrigger>
{index < openTabs.length && (
+174 -125
View File
@@ -11,6 +11,7 @@ import { useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { Plus, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useState } from 'react'
import {
cn,
ContextMenu,
@@ -27,8 +28,14 @@ import { CollapseButton } from './CollapseButton'
import { SortableTab } from './SortableTab'
import { TabPreview } from './TabPreview'
import { useTabsScroll } from './Tabs.utils'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
import { editorEntityTypes, useTabsStateSnapshot, type Tab } from '@/state/tabs'
import {
editorEntityTypes,
useTabsStateSnapshot,
type Tab,
type TabCloseConfirmation,
} from '@/state/tabs'
export const EditorTabs = () => {
const { ref, id } = useParams()
@@ -37,6 +44,8 @@ export const EditorTabs = () => {
const editor = useEditorType()
const tabs = useTabsStateSnapshot()
const [pendingClose, setPendingClose] = useState<(() => void) | null>(null)
const [pendingConfirmation, setPendingConfirmation] = useState<TabCloseConfirmation | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
@@ -76,8 +85,24 @@ export const EditorTabs = () => {
}
}
// Runs `performClose` immediately unless one of the tabs' registered close
// handlers asks to confirm first (e.g. a SQL snippet with unsaved edits), in
// which case a confirmation dialog is shown and `performClose` only runs if
// the user confirms. The layout stays agnostic of per-type close semantics.
const closeWithConfirmation = (tabIdsToClose: string[], performClose: () => void) => {
const confirmation = tabs.getCloseConfirmation(tabIdsToClose)
if (confirmation) {
setPendingConfirmation(confirmation)
setPendingClose(() => performClose)
} else {
performClose()
}
}
const handleClose = (tabId: string) => {
tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory })
closeWithConfirmation([tabId], () => {
tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory })
})
}
const handleCloseAll = () => {
@@ -87,9 +112,11 @@ export const EditorTabs = () => {
? tabs.openTabs.filter((x) => !x.startsWith('sql'))
: tabs.openTabs.filter((x) => x.startsWith('sql'))
tabs.removeTabs(tabsToClose)
onClearDashboardHistory()
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`)
closeWithConfirmation(tabsToClose, () => {
tabs.closeTabs(tabsToClose)
onClearDashboardHistory()
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`)
})
}
}
@@ -100,13 +127,15 @@ export const EditorTabs = () => {
? tabs.openTabs.filter((x) => !x.startsWith('sql') && x !== tabId)
: tabs.openTabs.filter((x) => x.startsWith('sql') && x !== tabId)
tabs.removeTabs(tabsToClose)
onClearDashboardHistory()
closeWithConfirmation(tabsToClose, () => {
tabs.closeTabs(tabsToClose)
onClearDashboardHistory()
const entityId = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1]
if (id !== entityId) {
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${entityId}`)
}
const entityId = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1]
if (id !== entityId) {
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${entityId}`)
}
})
}
}
@@ -119,13 +148,16 @@ export const EditorTabs = () => {
const tabIdx = openedTabs.indexOf(tabId)
const activeTabIdx = openedTabs.indexOf(tabs.activeTab!)
const tabsToClose = openedTabs.slice(tabIdx + 1)
tabs.removeTabs(tabsToClose)
const isActiveTabClosed = tabIdx < activeTabIdx
if (isActiveTabClosed) {
const id = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1]
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${id}`)
}
closeWithConfirmation(tabsToClose, () => {
tabs.closeTabs(tabsToClose)
const isActiveTabClosed = tabIdx < activeTabIdx
if (isActiveTabClosed) {
const id = editor === 'table' ? tabId.split('-')[1] : tabId.split('sql-')[1]
router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}/${id}`)
}
})
}
}
@@ -136,116 +168,133 @@ export const EditorTabs = () => {
const { tabsListRef } = useTabsScroll({ activeTab: tabs.activeTab, tabCount: editorTabs.length })
return (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<Tabs
className="w-full flex"
value={hasNewTab ? 'new' : (tabs.activeTab ?? undefined)}
onValueChange={handleTabChange}
>
<CollapseButton hideTabs={false} />
<TabsList
ref={tabsListRef}
className={cn(
'rounded-b-none gap-0 min-h-(--header-height) flex items-center w-full z-1',
'bg-surface-200 dark:bg-alternative border-none text-clip overflow-x-auto'
)}
<>
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<Tabs
className="w-full flex"
value={hasNewTab ? 'new' : (tabs.activeTab ?? undefined)}
onValueChange={handleTabChange}
>
<SortableContext
items={editorTabs.map((tab) => tab.id)}
strategy={horizontalListSortingStrategy}
>
{editorTabs.map((tab, index) => (
<ContextMenu key={tab.id}>
<ContextMenuTrigger>
<SortableTab
key={tab.id}
tab={tab}
index={index}
openTabs={openTabs}
onClose={() => handleClose(tab.id)}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleClose(tab.id)}>Close</ContextMenuItem>
<ContextMenuItem onClick={() => handleCloseOthers(tab.id)}>
Close Others
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCloseRight(tab.id)}>
Close to the Right
</ContextMenuItem>
<ContextMenuItem onClick={handleCloseAll}>Close All</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</SortableContext>
{/* Non-draggable new tab */}
{hasNewTab && (
<TabsTrigger
value="new"
className={cn(
'flex items-center gap-2 px-3 text-xs',
'bg-dash-sidebar/50 dark:bg-surface-100/50',
'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
'relative group h-full border-t-2 border-b-0!',
'hover:bg-surface-300 dark:hover:bg-surface-100'
)}
>
<Plus size={16} strokeWidth={1.5} className={'text-foreground-lighter'} />
<div className="flex items-center gap-0">
<span>New</span>
</div>
<span
role="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleClose('new')
}}
>
<X size={12} className="text-foreground-light" />
</span>{' '}
<div className="absolute w-full -bottom-px left-0 right-0 h-px bg-dash-sidebar dark:bg-surface-100 opacity-0 group-data-[state=active]:opacity-100" />
</TabsTrigger>
)}
<AnimatePresence initial={false}>
{!hasNewTab && (
<motion.button
className="flex items-center justify-center w-10 min-h-(--header-height) hover:bg-surface-100 shrink-0 border-b"
onClick={() =>
router.push(
`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true`
)
}
initial={{ opacity: 0, scale: 0.8, x: -10 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
transition={{ duration: 0.2 }}
>
<Plus
size={16}
strokeWidth={1.5}
className="text-foreground-lighter hover:text-foreground-light"
/>
</motion.button>
<CollapseButton hideTabs={false} />
<TabsList
ref={tabsListRef}
className={cn(
'rounded-b-none gap-0 min-h-(--header-height) flex items-center w-full z-1',
'bg-surface-200 dark:bg-alternative border-none text-clip overflow-x-auto'
)}
</AnimatePresence>
<div className="grow h-full border-b pr-6" />
</TabsList>
</Tabs>
>
<SortableContext
items={editorTabs.map((tab) => tab.id)}
strategy={horizontalListSortingStrategy}
>
{editorTabs.map((tab, index) => (
<ContextMenu key={tab.id}>
<ContextMenuTrigger>
<SortableTab
key={tab.id}
tab={tab}
index={index}
openTabs={openTabs}
onClose={() => handleClose(tab.id)}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleClose(tab.id)}>Close</ContextMenuItem>
<ContextMenuItem onClick={() => handleCloseOthers(tab.id)}>
Close Others
</ContextMenuItem>
<ContextMenuItem onClick={() => handleCloseRight(tab.id)}>
Close to the Right
</ContextMenuItem>
<ContextMenuItem onClick={handleCloseAll}>Close All</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
</SortableContext>
<DragOverlay dropAnimation={null}>
{tabs.activeTab ? <TabPreview tab={tabs.activeTab} /> : null}
</DragOverlay>
</DndContext>
{/* Non-draggable new tab */}
{hasNewTab && (
<TabsTrigger
value="new"
className={cn(
'flex items-center gap-2 px-3 text-xs',
'bg-dash-sidebar/50 dark:bg-surface-100/50',
'data-[state=active]:bg-dash-sidebar dark:data-[state=active]:bg-surface-100',
'relative group h-full border-t-2 border-b-0!',
'hover:bg-surface-300 dark:hover:bg-surface-100'
)}
>
<Plus size={16} strokeWidth={1.5} className={'text-foreground-lighter'} />
<div className="flex items-center gap-0">
<span>New</span>
</div>
<span
role="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
className="ml-1 opacity-0 group-hover:opacity-100 hover:bg-200 rounded-xs cursor-pointer"
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
handleClose('new')
}}
>
<X size={12} className="text-foreground-light" />
</span>{' '}
<div className="absolute w-full -bottom-px left-0 right-0 h-px bg-dash-sidebar dark:bg-surface-100 opacity-0 group-data-[state=active]:opacity-100" />
</TabsTrigger>
)}
<AnimatePresence initial={false}>
{!hasNewTab && (
<motion.button
className="flex items-center justify-center w-10 min-h-(--header-height) hover:bg-surface-100 shrink-0 border-b"
onClick={() =>
router.push(
`/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true`
)
}
initial={{ opacity: 0, scale: 0.8, x: -10 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
transition={{ duration: 0.2 }}
>
<Plus
size={16}
strokeWidth={1.5}
className="text-foreground-lighter hover:text-foreground-light"
/>
</motion.button>
)}
</AnimatePresence>
<div className="grow h-full border-b pr-6" />
</TabsList>
</Tabs>
<DragOverlay dropAnimation={null}>
{tabs.activeTab ? <TabPreview tab={tabs.activeTab} /> : null}
</DragOverlay>
</DndContext>
<DiscardChangesConfirmationDialog
visible={pendingClose !== null}
onClose={() => {
pendingClose?.()
setPendingClose(null)
setPendingConfirmation(null)
}}
onCancel={() => {
setPendingClose(null)
setPendingConfirmation(null)
}}
title={pendingConfirmation?.title ?? 'Unsaved changes'}
description={pendingConfirmation?.description}
/>
</>
)
}
@@ -60,6 +60,11 @@ export function statusOnEdit(status: SnippetStatus): SnippetStatus {
return status === 'saved' ? 'unsaved' : status
}
/** Transition when a snippet is discarded — the snippet is now either never persisted or clean. */
export function statusOnDiscard(status: SnippetStatus): SnippetStatus {
return wasNeverPersisted(status) ? 'new' : 'saved'
}
/**
* The lifecycle of a folder in the SQL editor nav, as a single set of
* mutually-exclusive states. Like SnippetStatus, this collapses two orthogonal
@@ -1,15 +1,28 @@
import { useQueryClient } from '@tanstack/react-query'
import { createContext, useContext, useEffect, useMemo, type PropsWithChildren } from 'react'
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
type PropsWithChildren,
} from 'react'
import { toast } from 'sonner'
import { hasUnsavedChanges } from './sql-editor-lifecycle'
import { createSaveMechanism } from './sql-editor-save'
import { createSaveScheduler, type SaveScheduler } from './sql-editor-save-scheduler'
import { createSaveScheduler, type SaveMode, type SaveScheduler } from './sql-editor-save-scheduler'
import { sqlEditorState } from './sql-editor-state'
import { useIsSqlEditorManualSaveEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import {
getSnippetIdFromTab,
SqlTabStatusIndicator,
} from '@/components/interfaces/SQLEditor/SqlTabStatusIndicator'
import { upsertContent } from '@/data/content/content-upsert-mutation'
import { contentKeys } from '@/data/content/keys'
import { createSQLSnippetFolder } from '@/data/content/sql-folder-create-mutation'
import { updateSQLSnippetFolder } from '@/data/content/sql-folder-update-mutation'
import { TabsStateContext, type Tab } from '@/state/tabs'
type SaveCoordinator = Pick<SaveScheduler, 'requestSave'>
@@ -26,6 +39,12 @@ const SqlEditorSaveCoordinatorContext = createContext<SaveCoordinator | null>(nu
export function SqlEditorSaveCoordinatorProvider({ children }: PropsWithChildren) {
const queryClient = useQueryClient()
const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
const saveModeRef = useRef<SaveMode>('auto')
useEffect(() => {
saveModeRef.current = isManualSaveEnabled ? 'manual' : 'auto'
}, [isManualSaveEnabled])
const scheduler = useMemo(() => {
const mechanism = createSaveMechanism({
state: sqlEditorState,
@@ -41,12 +60,60 @@ export function SqlEditorSaveCoordinatorProvider({ children }: PropsWithChildren
])
},
})
// getSaveMode defaults to 'auto'; the manual-save opt-in plugs in here later.
return createSaveScheduler({ state: sqlEditorState, saveMechanism: mechanism, notify: toast })
// getSaveMode is invoked synchronously from a Valtio `subscribe` callback,
// outside React's render cycle, so it can't read reactive state directly.
// Route it through a ref that's kept in sync via the effect above instead.
return createSaveScheduler({
state: sqlEditorState,
saveMechanism: mechanism,
notify: toast,
getSaveMode: () => saveModeRef.current,
})
}, [queryClient])
useEffect(() => scheduler.start(), [scheduler])
// Own what a SQL tab means to the tabs layout — how it closes and the
// unsaved-changes dot it shows — so the layout doesn't have to know about
// snippets. Discarding is a manual-save concept: only manual mode leaves
// unsaved local edits to throw away. In auto mode every edit is already
// persisted (or a debounced save is in flight), so closing must NOT touch the
// snippet's store content or cache — nulling a still-mounted editor's content
// crashes Monaco on dispose, and a snippet left with `content: undefined`
// silently drops the next edit (breaking autosave). Only when there are edits
// to discard do we confirm first, then clear the local content and evict the
// cached server copy so the snippet re-fetches clean when reopened.
const tabsStore = useContext(TabsStateContext)
useEffect(() => {
// A snippet has unsaved edits worth discarding only in manual mode.
const snippetHasUnsavedEdits = (tab: Tab) =>
saveModeRef.current === 'manual' &&
hasUnsavedChanges(sqlEditorState.snippets[getSnippetIdFromTab(tab)]?.snippet.status)
return tabsStore.registerTabTypeHandler('sql', {
// VS Code-style unsaved-changes dot, rendered by the tabs layout.
StatusIndicator: SqlTabStatusIndicator,
onClose: (tab) => {
if (!snippetHasUnsavedEdits(tab)) return
const snippetId = getSnippetIdFromTab(tab)
const projectRef = sqlEditorState.snippets[snippetId]?.projectRef
sqlEditorState.clearSnippetContent(snippetId)
queryClient.removeQueries({ queryKey: contentKeys.resource(projectRef, snippetId) })
},
confirmClose: (tabs) => {
const dirtyCount = tabs.filter(snippetHasUnsavedEdits).length
if (dirtyCount === 0) return null
return {
title: 'Unsaved changes',
description:
dirtyCount === 1
? 'You have unsaved changes in this SQL snippet. Closing it will discard them.'
: `You have unsaved changes in ${dirtyCount} SQL snippets. Closing them will discard those changes.`,
}
},
})
}, [tabsStore, queryClient])
// Warn before the tab is closed/reloaded while any snippet still has unsaved
// work (a failed save, a save in flight, or a never-saved snippet). In-app
// navigation isn't guarded — the store survives client-side route changes, so
@@ -4,7 +4,12 @@ import { toast } from 'sonner'
import { proxy, snapshot, useSnapshot } from 'valtio'
import { devtools, proxyMap } from 'valtio/utils'
import { folderStatusOnSaveStart, isNewFolder, statusOnEdit } from './sql-editor-lifecycle'
import {
folderStatusOnSaveStart,
isNewFolder,
statusOnDiscard,
statusOnEdit,
} from './sql-editor-lifecycle'
import { sqlEditorSessionState } from './sql-editor-session-state'
import type { StateSnippet, StateSnippetFolder } from './types'
import type { SnippetWithContent } from '@/data/content/sql-folders-query'
@@ -57,6 +62,19 @@ export const sqlEditorState = proxy({
sqlEditorState.snippets[snippet.id] = { projectRef, splitSizes: [50, 50], snippet }
},
/**
*
* Clear local snippet content that is not persisted to the database. Deletes
* user edits that have not been saved.
*/
clearSnippetContent: (id: string) => {
const storeSnippet = sqlEditorState.snippets[id]
if (storeSnippet) {
storeSnippet.snippet.content = undefined
storeSnippet.snippet.status = statusOnDiscard(storeSnippet.snippet.status)
}
},
/**
* Update snippet data (e.g name, visibility, chart) and queue for sync saving
*/
+142 -2
View File
@@ -1,8 +1,19 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { NextRouter } from 'next/router'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTabsState } from './tabs'
import { createTabsState, type Tab } from './tabs'
import { ENTITY_TYPE } from '@/data/entity-types/entity-type-constants'
const fakeRouter = () => ({ query: { ref: 'default' }, push: vi.fn() }) as unknown as NextRouter
const sqlTab = (id: string): Tab => ({
id: `sql-${id}`,
type: 'sql',
label: id,
isPreview: false,
metadata: { sqlId: id },
})
describe('tabs recent items', () => {
beforeEach(() => {
localStorage.clear()
@@ -127,3 +138,132 @@ describe('tabs removal', () => {
expect(store.openTabs).toEqual(['sql-b'])
})
})
describe('tabs close handlers', () => {
beforeEach(() => {
localStorage.clear()
})
it('runs the registered close handler when a single tab is closed', () => {
const store = createTabsState('default')
store.addTab(sqlTab('a'))
const onClose = vi.fn()
store.registerTabTypeHandler('sql', { onClose })
store.handleTabClose({ id: 'sql-a', router: fakeRouter(), onClearDashboardHistory: () => {} })
expect(onClose).toHaveBeenCalledTimes(1)
expect(onClose.mock.calls[0][0]).toMatchObject({ id: 'sql-a', metadata: { sqlId: 'a' } })
})
it('runs the close handler for every tab closed via closeTabs', () => {
const store = createTabsState('default')
store.addTab(sqlTab('a'))
store.addTab(sqlTab('b'))
const onClose = vi.fn()
store.registerTabTypeHandler('sql', { onClose })
store.closeTabs(['sql-a', 'sql-b'])
expect(onClose).toHaveBeenCalledTimes(2)
expect(store.openTabs).toHaveLength(0)
})
it('does not run close handlers for the low-level removeTab / removeTabs (re-keying, cleanup)', () => {
const store = createTabsState('default')
store.addTab(sqlTab('a'))
store.addTab(sqlTab('b'))
const onClose = vi.fn()
store.registerTabTypeHandler('sql', { onClose })
store.removeTab('sql-a')
store.removeTabs(['sql-b'])
expect(onClose).not.toHaveBeenCalled()
})
it('only runs the handler for the matching tab type', () => {
const store = createTabsState('default')
store.addTab(sqlTab('a'))
store.addTab({ id: 'r-1', type: ENTITY_TYPE.TABLE, label: 'tasks', isPreview: false })
const onClose = vi.fn()
store.registerTabTypeHandler('sql', { onClose })
store.closeTabs(['sql-a', 'r-1'])
expect(onClose).toHaveBeenCalledTimes(1)
expect(onClose.mock.calls[0][0]).toMatchObject({ id: 'sql-a' })
})
it('returns the confirmation from the handler when any closing tab needs it', () => {
const store = createTabsState('default')
store.addTab(sqlTab('clean'))
store.addTab(sqlTab('dirty'))
store.registerTabTypeHandler('sql', {
confirmClose: (tabs) =>
tabs.some((tab) => tab.metadata?.sqlId === 'dirty')
? { title: 'Unsaved changes', description: 'Closing will discard them.' }
: null,
})
expect(store.getCloseConfirmation(['sql-clean'])).toBeNull()
expect(store.getCloseConfirmation(['sql-clean', 'sql-dirty'])).toEqual({
title: 'Unsaved changes',
description: 'Closing will discard them.',
})
})
it('passes the full set of closing tabs to the handler so it owns the copy', () => {
const store = createTabsState('default')
store.addTab(sqlTab('a'))
store.addTab(sqlTab('b'))
store.addTab(sqlTab('c'))
// The handler — not the store — decides the wording, e.g. count-aware copy.
store.registerTabTypeHandler('sql', {
confirmClose: (tabs) => ({ title: 'Unsaved changes', description: `${tabs.length} tabs` }),
})
expect(store.getCloseConfirmation(['sql-a', 'sql-b', 'sql-c'])).toEqual({
title: 'Unsaved changes',
description: '3 tabs',
})
})
it('stops running a handler after it is unregistered', () => {
const store = createTabsState('default')
store.addTab(sqlTab('a'))
const onClose = vi.fn()
const unregister = store.registerTabTypeHandler('sql', { onClose })
unregister()
store.closeTabs(['sql-a'])
expect(onClose).not.toHaveBeenCalled()
})
it('exposes a registered status indicator and bumps the registration version', () => {
const store = createTabsState('default')
const Indicator = () => null
expect(store.getTabStatusIndicator('sql')).toBeUndefined()
const before = store.handlerRegistrationVersion
const unregister = store.registerTabTypeHandler('sql', { StatusIndicator: Indicator })
expect(store.getTabStatusIndicator('sql')).toBe(Indicator)
expect(store.handlerRegistrationVersion).toBeGreaterThan(before)
const afterRegister = store.handlerRegistrationVersion
unregister()
expect(store.getTabStatusIndicator('sql')).toBeUndefined()
expect(store.handlerRegistrationVersion).toBeGreaterThan(afterRegister)
})
})
+116 -1
View File
@@ -1,7 +1,14 @@
import { safeLocalStorage, useParams } from 'common'
import { partition } from 'lodash'
import { type NextRouter } from 'next/router'
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react'
import {
createContext,
PropsWithChildren,
useContext,
useEffect,
useState,
type ComponentType,
} from 'react'
import { proxy, subscribe, useSnapshot } from 'valtio'
import { buildTableEditorUrl } from '@/components/grid/SupabaseGrid.utils'
@@ -43,6 +50,42 @@ export interface Tab {
updatedAt?: Date
}
/** Copy shown in the confirmation dialog before a close is allowed to proceed. */
export interface TabCloseConfirmation {
title: string
description: string
}
/**
* Per-tab-type behavior and UI the tabs layout delegates to, so the layout
* stays agnostic of what any given tab kind means. A domain (e.g. the SQL
* editor) registers a handler for its tab type via `registerTabTypeHandler`;
* tabs of types without a handler close with no extra behavior and show no
* status indicator.
*/
export interface TabTypeHandler {
/**
* Cleanup to run when the user closes a tab of this type (e.g. discarding a
* SQL snippet's unsaved local edits). Runs after the tab has been removed.
*/
onClose?: (tab: Tab) => void
/**
* Whether closing these tabs needs user confirmation. Receives the whole set
* of this type being closed (e.g. a bulk "Close Others") so the handler owns
* the dialog copy, including wording it for one vs. many. Return the copy to
* confirm first; return null/undefined to close immediately.
*/
confirmClose?: (tabs: Tab[]) => TabCloseConfirmation | null | undefined
/**
* Optional component rendered inside the tab to show type-specific status
* (e.g. a VS Code-style unsaved-changes dot for a SQL snippet). Owning the
* component here keeps the layout agnostic of what "status" means per type and
* lets the domain drive its own reactivity. Rendered only when it has
* something to show; otherwise it should render nothing.
*/
StatusIndicator?: ComponentType<{ tab: Tab }>
}
const MAX_RECENT_ITEMS = 8
export interface RecentItem {
@@ -128,6 +171,11 @@ export function createTabsState(projectRef: string) {
const recentItems = getSavedRecentItems(projectRef)
const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef)
// Per-type behavior/UI, kept outside the Valtio proxy so handler closures
// (which may capture non-serializable things like a React Query client or a
// React component) are never proxied or persisted.
const tabHandlers = new Map<TabType, TabTypeHandler>()
const store = proxy({
// RECENT ITEMS
recentItems,
@@ -328,6 +376,67 @@ export function createTabsState(projectRef: string) {
break
}
},
// TAB TYPE HANDLER REGISTRY
//
// Lets a domain own what a tab of its type means — how it closes and what
// status it shows — without the layout having to know. Registered per tab
// type; returns an unregister function.
//
// Bumped on every (un)register so components that render per-type UI (the
// status indicator) re-render to pick up a handler registered after they
// first rendered — handlers register in an effect, which runs after the
// tabs first paint.
handlerRegistrationVersion: 0,
registerTabTypeHandler: (type: TabType, handler: TabTypeHandler) => {
tabHandlers.set(type, handler)
store.handlerRegistrationVersion++
return () => {
if (tabHandlers.get(type) === handler) {
tabHandlers.delete(type)
store.handlerRegistrationVersion++
}
}
},
// The status-indicator component registered for a tab type, if any. Read
// `handlerRegistrationVersion` alongside this in render to stay reactive to
// late registration.
getTabStatusIndicator: (type: TabType) => tabHandlers.get(type)?.StatusIndicator,
// The confirmation to show before closing the given tabs, or null if none
// need confirming. Tabs are grouped by type and each type's handler is asked
// about its own set (so it can word the copy for one vs. many); the first
// handler that asks to confirm wins. The store authors no copy itself — that
// stays a concern of the registering domain.
getCloseConfirmation: (ids: string[]): TabCloseConfirmation | null => {
const tabsByType = new Map<TabType, Tab[]>()
for (const id of ids) {
const tab = store.tabsMap[id]
if (!tab) continue
const group = tabsByType.get(tab.type)
if (group) group.push(tab)
else tabsByType.set(tab.type, [tab])
}
for (const [type, tabs] of tabsByType) {
const confirmation = tabHandlers.get(type)?.confirmClose?.(tabs)
if (confirmation) return confirmation
}
return null
},
// Close multiple tabs as an intentional user action, running each tab type's
// close handler afterwards. Distinct from `removeTabs`, the low-level store
// mutation used for re-keying (rename/move) and stale cleanup, which must
// NOT trigger discard behavior.
closeTabs: (ids: string[]) => {
const closedTabs = ids
.map((id) => store.tabsMap[id])
.filter((tab): tab is Tab => tab !== undefined)
store.removeTabs(ids)
closedTabs.forEach((tab) => tabHandlers.get(tab.type)?.onClose?.(tab))
},
handleTabClose: ({
id,
router,
@@ -399,6 +508,12 @@ export function createTabsState(projectRef: string) {
}
onClose?.(id)
// Run the tab type's registered close behavior (e.g. discard a SQL
// snippet's unsaved edits). `tabBeingClosed` is captured before removal.
if (tabBeingClosed) {
tabHandlers.get(tabBeingClosed.type)?.onClose?.(tabBeingClosed)
}
},
handleTabCloseAll: ({
editor,
@@ -26,6 +26,7 @@ export const LOCAL_STORAGE_KEYS = {
UI_PREVIEW_PLATFORM_WEBHOOKS: 'supabase-ui-platform-webhooks',
UI_PREVIEW_JIT_DB_ACCESS: 'supabase-ui-jit-db-access',
UI_PREVIEW_RLS_TESTER: 'supabase-ui-rls-tester',
UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE: 'supabase-ui-sql-editor-manual-save',
UI_PREVIEW_MARKETPLACE: 'supabase-ui-marketplace',
AI_ASSISTANT_MCP_OPT_IN: 'ai-assistant-mcp-opt-in',
@@ -154,6 +155,7 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [
LOCAL_STORAGE_KEYS.UI_PREVIEW_UNIFIED_LOGS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS,
LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE,
LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE,
LOCAL_STORAGE_KEYS.LAST_SIGN_IN_METHOD,
LOCAL_STORAGE_KEYS.HIDE_PROMO_TOAST,
+14
View File
@@ -694,6 +694,19 @@ export interface SqlEditorTemplateClickedEvent {
groups: TelemetryGroups
}
/**
* User clicked the "Disable" button next to the autosave status text in the
* SQL Editor, to open the feature preview modal for manual snippet saving.
*
* @group Events
* @source studio
* @page /project/{ref}/sql/{id}
*/
export interface SqlEditorAutosaveDisableClickedEvent {
action: 'sql_editor_autosave_disable_clicked'
groups: TelemetryGroups
}
/**
* User clicked the "Result download CSV" button in the SQL editor.
*
@@ -3583,6 +3596,7 @@ export type TelemetryEvent =
| TableRealtimeDisabledEvent
| SqlEditorQuickstartClickedEvent
| SqlEditorTemplateClickedEvent
| SqlEditorAutosaveDisableClickedEvent
| SqlEditorResultDownloadCsvClickedEvent
| SqlEditorResultCopyMarkdownClickedEvent
| SqlEditorResultCopyJsonClickedEvent