mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
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 && (
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user