From 1987f19d0a511eb8a7f27ab216df3eb20b4e5a1a Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:37:38 -0400 Subject: [PATCH] feat(sql-editor): add manual save feature preview (#47745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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). ## 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. --- .../FeaturePreview/FeaturePreviewContext.tsx | 6 + .../FeaturePreview/FeaturePreviewModal.tsx | 2 + .../SqlEditorManualSavePreview.tsx | 17 + .../App/FeaturePreview/useFeaturePreviews.ts | 18 +- .../SQLEditor/SqlTabStatusIndicator.tsx | 33 ++ .../SQLEditor/UtilityPanel/AutosaveStatus.tsx | 70 ++++ .../SQLEditor/UtilityPanel/SaveButton.tsx | 40 +++ .../SQLEditor/UtilityPanel/UtilityActions.tsx | 19 +- .../components/layouts/Tabs/SortableTab.tsx | 53 ++-- apps/studio/components/layouts/Tabs/Tabs.tsx | 299 ++++++++++-------- .../state/sql-editor/sql-editor-lifecycle.ts | 5 + .../sql-editor-save-coordinator.tsx | 75 ++++- .../state/sql-editor/sql-editor-state.ts | 20 +- apps/studio/state/tabs.test.ts | 144 ++++++++- apps/studio/state/tabs.tsx | 117 ++++++- packages/common/constants/local-storage.ts | 2 + packages/common/telemetry-constants.ts | 14 + 17 files changed, 777 insertions(+), 157 deletions(-) create mode 100644 apps/studio/components/interfaces/App/FeaturePreview/SqlEditorManualSavePreview.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/SqlTabStatusIndicator.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/AutosaveStatus.tsx create mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/SaveButton.tsx 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 && ( + + +
+ ) +} 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