diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
index ebae4de5b4c..54680d64291 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
+++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx
@@ -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]
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
index 20d63b4593a..179baa81dd5 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
+++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx
@@ -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]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_JIT_DB_ACCESS]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_TESTER]: ,
+ [LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE]: ,
[LOCAL_STORAGE_KEYS.UI_PREVIEW_MARKETPLACE]: ,
}
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx
new file mode 100644
index 00000000000..31d24422693
--- /dev/null
+++ b/apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx
@@ -0,0 +1,17 @@
+export const SqlEditorManualSavePreview = () => {
+ return (
+
+
+ Switch the SQL Editor from autosaving every edit to saving only when you ask it to.
+
+
+
Enabling this preview will:
+
+ - Stop auto-saving snippet edits as you type
+ - Add a Save button next to Run in the SQL Editor toolbar
+ - Let you save with Cmd+S at any time
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
index 1cdb5b0c3b0..c256c2ffac2 100644
--- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
+++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts
@@ -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,
+ ]
)
}
diff --git a/apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx b/apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx
new file mode 100644
index 00000000000..3ecea2464f7
--- /dev/null
+++ b/apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx
@@ -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 (
+
+ )
+}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx
new file mode 100644
index 00000000000..abff6c14b7a
--- /dev/null
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx
@@ -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 (
+
+ {unsavedChanges ? 'Unsaved edits' : 'Saved'}
+
+ )
+ }
+
+ return (
+
+ Autosave enabled
+ {canEnableManualSave && (
+
+
+ }
+ onClick={() => {
+ track('sql_editor_autosave_disable_clicked')
+ selectFeaturePreview(LOCAL_STORAGE_KEYS.UI_PREVIEW_SQL_EDITOR_MANUAL_SAVE)
+ }}
+ />
+
+ Disable autosave (feature preview)
+
+ )}
+
+ )
+}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx
new file mode 100644
index 00000000000..87ce9d5e08b
--- /dev/null
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx
@@ -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 (
+
+ )
+}
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx
index 7164ea180ec..ed3809fbc56 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx
@@ -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 (
- {IS_PLATFORM &&
}
+
+ {/* SavingIndicator reports auto-save progress (spinner/checkmark). In manual
+ mode AutosaveStatus + the Save button own the status, so hide it there. */}
+ {IS_PLATFORM && !isManualSaveEnabled &&
}
@@ -204,7 +211,7 @@ export const UtilityActions = ({
-
+
{IS_PLATFORM && (
+
+
+
+ {isManualSaveEnabled && }
diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx
index 80cc206197a..4e9a99d2095 100644
--- a/apps/studio/components/layouts/Tabs/SortableTab.tsx
+++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx
@@ -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 = ({
{tab.label || 'Untitled'}
-
{
- 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)
- }}
- >
-
-
+ {/* 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. */}
+
+ {StatusIndicator && (
+
+
+
+ )}
+ {
+ 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)
+ }}
+ >
+
+
+
{index < openTabs.length && (
diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx
index 7ca8624837a..c1959e98717 100644
--- a/apps/studio/components/layouts/Tabs/Tabs.tsx
+++ b/apps/studio/components/layouts/Tabs/Tabs.tsx
@@ -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
(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 (
-
-
-
-
+
+
- tab.id)}
- strategy={horizontalListSortingStrategy}
- >
- {editorTabs.map((tab, index) => (
-
-
- handleClose(tab.id)}
- />
-
-
- handleClose(tab.id)}>Close
- handleCloseOthers(tab.id)}>
- Close Others
-
- handleCloseRight(tab.id)}>
- Close to the Right
-
- Close All
-
-
- ))}
-
-
- {/* Non-draggable new tab */}
- {hasNewTab && (
-
-
-
- New
-
- {
- 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')
- }}
- >
-
- {' '}
-
-
- )}
-
-
- {!hasNewTab && (
-
- 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 }}
- >
-
-
+
+
-
-
-
+ >
+ tab.id)}
+ strategy={horizontalListSortingStrategy}
+ >
+ {editorTabs.map((tab, index) => (
+
+
+ handleClose(tab.id)}
+ />
+
+
+ handleClose(tab.id)}>Close
+ handleCloseOthers(tab.id)}>
+ Close Others
+
+ handleCloseRight(tab.id)}>
+ Close to the Right
+
+ Close All
+
+
+ ))}
+
-
- {tabs.activeTab ? : null}
-
-
+ {/* Non-draggable new tab */}
+ {hasNewTab && (
+
+
+
+ New
+
+ {
+ 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')
+ }}
+ >
+
+ {' '}
+
+
+ )}
+
+
+ {!hasNewTab && (
+
+ 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 }}
+ >
+
+
+ )}
+
+
+
+
+
+
+ {tabs.activeTab ? : null}
+
+
+
+ {
+ pendingClose?.()
+ setPendingClose(null)
+ setPendingConfirmation(null)
+ }}
+ onCancel={() => {
+ setPendingClose(null)
+ setPendingConfirmation(null)
+ }}
+ title={pendingConfirmation?.title ?? 'Unsaved changes'}
+ description={pendingConfirmation?.description}
+ />
+ >
)
}
diff --git a/apps/studio/state/sql-editor/sql-editor-lifecycle.ts b/apps/studio/state/sql-editor/sql-editor-lifecycle.ts
index 190231b1fb7..8502b51c805 100644
--- a/apps/studio/state/sql-editor/sql-editor-lifecycle.ts
+++ b/apps/studio/state/sql-editor/sql-editor-lifecycle.ts
@@ -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
diff --git a/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx b/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx
index a582e91753f..b5919477827 100644
--- a/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx
+++ b/apps/studio/state/sql-editor/sql-editor-save-coordinator.tsx
@@ -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
@@ -26,6 +39,12 @@ const SqlEditorSaveCoordinatorContext = createContext(nu
export function SqlEditorSaveCoordinatorProvider({ children }: PropsWithChildren) {
const queryClient = useQueryClient()
+ const isManualSaveEnabled = useIsSqlEditorManualSaveEnabled()
+ const saveModeRef = useRef('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
diff --git a/apps/studio/state/sql-editor/sql-editor-state.ts b/apps/studio/state/sql-editor/sql-editor-state.ts
index 52e79e2252d..6e1c6cdc545 100644
--- a/apps/studio/state/sql-editor/sql-editor-state.ts
+++ b/apps/studio/state/sql-editor/sql-editor-state.ts
@@ -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
*/
diff --git a/apps/studio/state/tabs.test.ts b/apps/studio/state/tabs.test.ts
index 0bd4d81494e..9cbfa766a10 100644
--- a/apps/studio/state/tabs.test.ts
+++ b/apps/studio/state/tabs.test.ts
@@ -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)
+ })
+})
diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx
index 310e6a5c30a..2b72256579b 100644
--- a/apps/studio/state/tabs.tsx
+++ b/apps/studio/state/tabs.tsx
@@ -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()
+
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()
+ 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,
diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts
index b31810f31eb..9671d925e4e 100644
--- a/packages/common/constants/local-storage.ts
+++ b/packages/common/constants/local-storage.ts
@@ -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,
diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts
index 5ecb01633e3..659541bcc9e 100644
--- a/packages/common/telemetry-constants.ts
+++ b/packages/common/telemetry-constants.ts
@@ -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