diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx
index 8fd55569840..16e8076a113 100644
--- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx
@@ -1,7 +1,6 @@
import Editor, { Monaco, OnMount } from '@monaco-editor/react'
-import { useDebounce } from '@uidotdev/usehooks'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
-import { MutableRefObject, useEffect, useRef, useState } from 'react'
+import { MutableRefObject, useEffect, useRef } from 'react'
import { cn } from 'ui'
import { Admonition } from 'ui-patterns'
import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu'
@@ -68,14 +67,14 @@ const MonacoEditor = ({
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
true
)
+ const [autoSaveSnippets] = useLocalStorageQuery(
+ LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
+ true
+ )
const isAIAssistantHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.AI_ASSISTANT_TOGGLE)
const isCommandMenuHotkeyEnabled = useIsShortcutEnabled(SHORTCUT_IDS.COMMAND_MENU_OPEN)
const setCommandMenuOpen = useSetCommandMenuOpen()
- // [Joshen] Lodash debounce doesn't seem to be working here, so opting to use useDebounce
- const [value, setValue] = useState('')
- const debouncedValue = useDebounce(value, 1000)
-
const snippet = snapV2.snippets[id]
const disableEdit =
snippet?.snippet.visibility === 'project' && snippet?.snippet.owner_id !== profile?.id
@@ -244,24 +243,19 @@ const MonacoEditor = ({
function handleEditorChange(value: string | undefined) {
tabsSnap.makeActiveTabPermanent()
- if (id && value !== undefined) {
- setValue(value)
- }
- }
-
- useEffect(() => {
- if (debouncedValue.length > 0 && snippet) {
- // Drafts persist to local storage (handled within setSql); saved snippets queue a debounced
- // DB save exactly as before.
+ if (id && value !== undefined && snippet) {
+ // Drafts persist to local storage (handled within setSql); saved snippets either autosave or
+ // stay local until the user explicitly saves, depending on the preference.
if (snippet.snippet.isDraftTab) {
snapV2.setSql({ id, sql: value, skipSave: true })
} else {
- const shouldInvalidate = snippet.snippet.isNotSavedInDatabaseYet
- snapV2.setSql({ id, sql: value, shouldInvalidate })
+ snapV2.setSql({ id, sql: value })
+ if (autoSaveSnippets) {
+ snapV2.addNeedsSaving(id, { saveSql: true })
+ }
}
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [debouncedValue])
+ }
// if an SQL query is passed by the content parameter, set the editor value to its content. This
// is usually used for sending the user to SQL editor from other pages with SQL.
diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
index 5de4d046edf..fb4f8aea7bd 100644
--- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx
@@ -142,6 +142,7 @@ export const RenameQueryModal = ({
snippet: { isDraftTab: false, isNotSavedInDatabaseYet: false },
skipSave: true,
})
+ snapV2.setSavedSql(id)
}
const tabId = createTabId('sql', { id })
diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
index 69f87c12d7b..0bc3e60744e 100644
--- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
@@ -32,7 +32,11 @@ import {
import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
import { RenameQueryModal } from './RenameQueryModal'
import { RunQueryWarningModal } from './RunQueryWarningModal'
-import { ROWS_PER_PAGE_OPTIONS, sqlAiDisclaimerComment } from './SQLEditor.constants'
+import {
+ ROWS_PER_PAGE_OPTIONS,
+ sqlAiDisclaimerComment,
+ untitledSnippetTitle,
+} from './SQLEditor.constants'
import {
DiffType,
IStandaloneCodeEditor,
@@ -62,15 +66,18 @@ import {
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget'
import { GridFooter } from '@/components/ui/GridFooter'
+import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation'
import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query'
import { constructHeaders, isValidConnString } from '@/data/fetchers'
import { lintKeys } from '@/data/lint/keys'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
import { isError } from '@/data/utils/error-check'
+import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
+import { useOrgAiOptInLevel } from '@/hooks/misc/useOrgOptedIntoAi'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
-import { BASE_PATH } from '@/lib/constants'
+import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
import { formatSql } from '@/lib/formatSql'
import { detectOS } from '@/lib/helpers'
import { wrapWithRoleImpersonation } from '@/lib/role-impersonation'
@@ -108,7 +115,12 @@ export const SQLEditor = () => {
const snapV2 = useSqlEditorV2StateSnapshot()
const getImpersonatedRoleState = useGetImpersonatedRoleState()
const databaseSelectorState = useDatabaseSelectorStateSnapshot()
+ const { aiOptInLevel, isHipaaProjectDisallowed } = useOrgAiOptInLevel()
const showPrettyExplain = useFlag('ShowPrettyExplain')
+ const [autoSaveSnippets] = useLocalStorageQuery(
+ LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
+ true
+ )
const {
sourceSqlDiff,
@@ -279,6 +291,23 @@ export const SQLEditor = () => {
},
})
+ const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
+
+ const setAiTitle = useCallback(
+ async (id: string, sql: string) => {
+ try {
+ const { title: name } = await generateSqlTitle({ sql })
+ snapV2.updateSnippet({ id, snippet: { name } })
+ snapV2.addNeedsSaving(id)
+ const tabId = createTabId('sql', { id })
+ tabs.updateTab(tabId, { label: name })
+ } catch {
+ // Title generation is best-effort and should not interrupt query execution.
+ }
+ },
+ [generateSqlTitle, snapV2, tabs]
+ )
+
const prettifyQuery = useCallback(async () => {
if (isDiffOpen) return
@@ -364,6 +393,17 @@ export const SQLEditor = () => {
return
}
+ if (
+ autoSaveSnippets &&
+ aiOptInLevel !== 'disabled' &&
+ !isHipaaProjectDisallowed &&
+ snippet?.snippet.name.startsWith(untitledSnippetTitle) &&
+ IS_PLATFORM
+ ) {
+ // Intentionally don't await title generation.
+ setAiTitle(id, sql)
+ }
+
if (lineHighlights.length > 0) {
editor?.deltaDecorations(lineHighlights, [])
setLineHighlights([])
@@ -404,8 +444,12 @@ export const SQLEditor = () => {
id,
isExecuting,
project,
+ autoSaveSnippets,
+ aiOptInLevel,
+ isHipaaProjectDisallowed,
execute,
getImpersonatedRoleState,
+ setAiTitle,
databaseSelectorState.selectedDatabaseId,
databases,
eventTriggers,
@@ -532,8 +576,8 @@ export const SQLEditor = () => {
snapV2.setSql({ id, sql, skipSave: true })
setRenameModalOpen(true)
} else {
- snapV2.setSql({ id, sql, shouldInvalidate: snippet.isNotSavedInDatabaseYet })
- snapV2.addNeedsSaving(id)
+ snapV2.setSql({ id, sql, skipSave: true })
+ snapV2.addNeedsSaving(id, { saveSql: true })
}
}, [id, snapV2])
diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx
index 62035442683..37aa1bf3d18 100644
--- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx
+++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx
@@ -1,18 +1,14 @@
import { Hotkey } from '@tanstack/react-hotkeys'
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
-import { AlignLeft, Check, Heart, Keyboard, MoreVertical } from 'lucide-react'
+import { AlignLeft, Check, Keyboard, MoreVertical, Save } from 'lucide-react'
import { toast } from 'sonner'
import {
Button,
- cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
KeyboardShortcut,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
} from 'ui'
import { SqlRunButton } from './RunButton'
@@ -50,19 +46,27 @@ export const UtilityActions = ({
const { profile } = useProfile()
const snapV2 = useSqlEditorV2StateSnapshot()
- const [isAiOpen] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.SQL_EDITOR_AI_OPEN, true)
const [intellisenseEnabled, setIntellisenseEnabled] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_INTELLISENSE,
true
)
+ const [autoSaveSnippets, setAutoSaveSnippets] = useLocalStorageQuery(
+ LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
+ true
+ )
const [lastSelectedDb, setLastSelectedDb] = useLocalStorageQuery(
LOCAL_STORAGE_KEYS.SQL_EDITOR_LAST_SELECTED_DB(ref as string),
''
)
const snippet = snapV2.snippets[id]
- const isFavorite = snippet !== undefined ? snippet.snippet.favorite : false
const isSaving = snapV2.savingStates[id] === 'UPDATING'
+ const sql = snippet?.snippet.content?.unchecked_sql ?? ''
+ const isDraft = snippet?.snippet.isDraftTab === true
+ const savedSql = snapV2.savedSql[id]
+ const hasUnsavedSqlChanges = isDraft
+ ? sql.trim().length > 0
+ : savedSql !== undefined && sql !== savedSql
const isReadOnly =
snippet?.snippet.visibility === 'project' && snippet?.snippet.owner_id !== profile?.id
@@ -77,9 +81,9 @@ export const UtilityActions = ({
)
}
- const addFavorite = () => snapV2.addFavorite(id)
-
- const removeFavorite = () => snapV2.removeFavorite(id)
+ const toggleAutoSaveSnippets = () => {
+ setAutoSaveSnippets(!autoSaveSnippets)
+ }
const onSelectDatabase = (databaseId: string) => {
snapV2.resetResults(id)
@@ -95,7 +99,7 @@ export const UtilityActions = ({
}
/>
@@ -114,75 +118,20 @@ export const UtilityActions = ({
{formatKeys && }
+
+
+
+ Auto save snippets
+
+ {autoSaveSnippets && }
+
-
-
-
- }
- />
-
-
-
- Intellisense enabled
- {intellisenseEnabled && }
-
-
-
-
- {IS_PLATFORM && (
-
-
- {isFavorite ? (
- }
- />
- ) : (
- }
- />
- )}
-
-
- {isFavorite ? 'Remove from' : 'Add to'} favorites
-
-
- )}
-
-
-
- }
- />
-
-
-
- Prettify SQL
- {formatKeys && }
-
-
-
-
-
- {IS_PLATFORM && (
+ {IS_PLATFORM && (!autoSaveSnippets || isDraft) && (
diff --git a/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.test.ts b/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.test.ts
index 4bef39f2d1c..0aa5b2126e8 100644
--- a/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.test.ts
+++ b/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.test.ts
@@ -1,15 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
- countDraftSqlTabsRequiringCloseConfirmation,
+ countSqlTabsRequiringCloseConfirmation,
createDraftSqlTab,
- getDiscardDraftSqlTabsDialogCopy,
+ getDiscardSqlTabsDialogCopy,
getDraftSqlTabSql,
getOpenDraftSqlTabIds,
isDraftSqlSnippet,
restoreDraftSqlTab,
restoreOpenDraftSqlTabs,
- shouldConfirmCloseDraftSqlTab,
+ shouldConfirmCloseSqlTab,
shouldHideDraftSqlTabFromNav,
} from './createDraftSqlTab'
import { persistDraftSqlTab, readPersistedDraftSqlTab } from './draftSqlTabStorage.utils'
@@ -83,61 +83,75 @@ describe('getDraftSqlTabSql', () => {
})
})
-describe('shouldConfirmCloseDraftSqlTab', () => {
+describe('shouldConfirmCloseSqlTab', () => {
const draftTab = { type: 'sql', metadata: { isDraft: true, sqlId: 'draft-1' } }
- it('requires confirmation only for draft sql tabs with content', () => {
- expect(shouldConfirmCloseDraftSqlTab(draftTab, 'select 1')).toBe(true)
- expect(shouldConfirmCloseDraftSqlTab(draftTab, ' ')).toBe(false)
- expect(
- shouldConfirmCloseDraftSqlTab({ type: 'sql', metadata: { sqlId: 'draft-1' } }, 'select 1')
- ).toBe(false)
- expect(
- shouldConfirmCloseDraftSqlTab({ type: 'r', metadata: { isDraft: true } }, 'select 1')
- ).toBe(false)
+ it('requires confirmation for draft sql tabs with content', () => {
+ expect(shouldConfirmCloseSqlTab(draftTab, 'select 1')).toBe(true)
+ expect(shouldConfirmCloseSqlTab(draftTab, ' ')).toBe(false)
+ expect(shouldConfirmCloseSqlTab({ type: 'r', metadata: { isDraft: true } }, 'select 1')).toBe(
+ false
+ )
+ })
+
+ it('requires confirmation for saved sql tabs with dirty content', () => {
+ const savedTab = { type: 'sql', metadata: { sqlId: 'saved-1' } }
+
+ expect(shouldConfirmCloseSqlTab(savedTab, 'select 2', 'select 1')).toBe(true)
+ expect(shouldConfirmCloseSqlTab(savedTab, 'select 1', 'select 1')).toBe(false)
+ expect(shouldConfirmCloseSqlTab(savedTab, 'select 1')).toBe(false)
})
})
-describe('countDraftSqlTabsRequiringCloseConfirmation', () => {
+describe('countSqlTabsRequiringCloseConfirmation', () => {
const draftTab = (sqlId: string) => ({
type: 'sql',
metadata: { isDraft: true, sqlId },
})
- it('counts only draft sql tabs with content', () => {
+ it('counts dirty saved sql tabs and draft sql tabs with content', () => {
const tabsMap = {
'sql-a': draftTab('draft-a'),
'sql-b': draftTab('draft-b'),
'sql-c': { type: 'sql', metadata: { sqlId: 'saved-c' } },
'sql-d': draftTab('draft-d'),
+ 'sql-e': { type: 'sql', metadata: { sqlId: 'saved-e' } },
}
const getTabSql = (tabId: string) => {
if (tabId === 'sql-b') return ' '
if (tabId === 'sql-d') return 'select d'
+ if (tabId === 'sql-c') return 'select changed'
+ if (tabId === 'sql-e') return 'select e'
return 'select a'
}
+ const getSavedSql = (tabId: string) => {
+ if (tabId === 'sql-c') return 'select c'
+ if (tabId === 'sql-e') return 'select e'
+ return undefined
+ }
expect(
- countDraftSqlTabsRequiringCloseConfirmation(
- ['sql-a', 'sql-b', 'sql-c', 'sql-d'],
+ countSqlTabsRequiringCloseConfirmation(
+ ['sql-a', 'sql-b', 'sql-c', 'sql-d', 'sql-e'],
tabsMap,
- getTabSql
+ getTabSql,
+ getSavedSql
)
- ).toBe(2)
+ ).toBe(3)
})
})
-describe('getDiscardDraftSqlTabsDialogCopy', () => {
+describe('getDiscardSqlTabsDialogCopy', () => {
it('uses singular copy for a single tab', () => {
- expect(getDiscardDraftSqlTabsDialogCopy(1)).toMatchObject({
- title: 'Discard unsaved query?',
+ expect(getDiscardSqlTabsDialogCopy(1)).toMatchObject({
+ title: 'Discard unsaved changes?',
confirmLabel: 'Discard query',
})
})
it('uses plural copy with a count for multiple tabs', () => {
- expect(getDiscardDraftSqlTabsDialogCopy(3)).toMatchObject({
+ expect(getDiscardSqlTabsDialogCopy(3)).toMatchObject({
title: 'Close 3 tabs with unsaved changes?',
description:
'You are about to close 3 tabs that have unsaved changes. Their contents will be discarded.',
@@ -183,6 +197,34 @@ describe('createDraftSqlTab', () => {
// Persisted to local storage with the initial sql
expect(readPersistedDraftSqlTab(PROJECT_REF, draftId)).toMatchObject({ sql: 'select 1' })
})
+
+ it('registers an autosaved snippet + tab and queues the initial save', () => {
+ const addSnippet = vi.fn()
+ const addNeedsSaving = vi.fn()
+ const addTab = vi.fn()
+
+ const snippetId = createDraftSqlTab({
+ projectRef: PROJECT_REF,
+ projectId: 1,
+ ownerId: 2,
+ snapV2: { addSnippet, addNeedsSaving },
+ tabs: { addTab },
+ initialSql: 'select 1',
+ creationMode: 'saved',
+ skipNavigation: true,
+ })
+
+ expect(addSnippet).toHaveBeenCalledTimes(1)
+ const addedSnippet = addSnippet.mock.calls[0][0].snippet
+ expect(addedSnippet.id).toBe(snippetId)
+ expect(addedSnippet.isDraftTab).toBeUndefined()
+
+ expect(addNeedsSaving).toHaveBeenCalledWith(snippetId, { saveSql: true })
+ expect(addTab).toHaveBeenCalledTimes(1)
+ expect(addTab.mock.calls[0][0].metadata).toMatchObject({ sqlId: snippetId })
+ expect(addTab.mock.calls[0][0].metadata.isDraft).toBeUndefined()
+ expect(readPersistedDraftSqlTab(PROJECT_REF, snippetId)).toBeUndefined()
+ })
})
describe('restoreDraftSqlTab', () => {
diff --git a/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.ts b/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.ts
index 3416ed7b8f9..d9360a9a7e3 100644
--- a/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.ts
+++ b/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.ts
@@ -13,6 +13,7 @@ import { createTabId } from '@/state/tabs'
type SqlEditorV2Actions = {
addSnippet: (args: { projectRef: string; snippet: SnippetWithContent }) => void
+ addNeedsSaving?: (id: string, options?: { saveSql?: boolean }) => void
}
type TabsActions = {
@@ -37,6 +38,7 @@ export type CreateDraftSqlTabParams = {
tabs: TabsActions
router?: NextRouter
initialSql?: string
+ creationMode?: 'draft' | 'saved'
/** When true, only registers state without changing the route */
skipNavigation?: boolean
}
@@ -49,47 +51,54 @@ export function createDraftSqlTab({
tabs,
router,
initialSql = '',
+ creationMode = 'draft',
skipNavigation = false,
}: CreateDraftSqlTabParams): string {
const name = generateSnippetTitle()
- const draftId = generateUuid([`${name}.sql`, Date.now().toString()])
+ const snippetId = generateUuid([`${name}.sql`, Date.now().toString()])
const snippet = createSqlSnippetSkeletonV2({
- idOverride: draftId,
+ idOverride: snippetId,
name,
sql: initialSql,
owner_id: ownerId,
project_id: projectId,
})
- snippet.isDraftTab = true
+ if (creationMode === 'draft') {
+ snippet.isDraftTab = true
+ }
snapV2.addSnippet({ projectRef, snippet })
- persistDraftSqlTab(projectRef, draftId, {
- sql: initialSql,
- name,
- })
+ if (creationMode === 'draft') {
+ persistDraftSqlTab(projectRef, snippetId, {
+ sql: initialSql,
+ name,
+ })
+ } else {
+ snapV2.addNeedsSaving?.(snippetId, { saveSql: true })
+ }
tabs.addTab({
- id: createTabId('sql', { id: draftId }),
+ id: createTabId('sql', { id: snippetId }),
type: 'sql',
label: name,
metadata: {
- sqlId: draftId,
+ sqlId: snippetId,
name,
- isDraft: true,
+ ...(creationMode === 'draft' ? { isDraft: true } : {}),
},
- // Drafts are permanent tabs (not preview), so opening another draft never evicts an existing
+ // New query tabs are permanent (not preview), so opening another one never evicts an existing
// empty one — each "new query" gets its own tab.
isPreview: false,
})
if (!skipNavigation && router) {
- void router.push(`/project/${projectRef}/sql/${draftId}`)
+ void router.push(`/project/${projectRef}/sql/${snippetId}`)
}
- return draftId
+ return snippetId
}
export function restoreDraftSqlTab({
@@ -205,7 +214,7 @@ export function shouldHideDraftSqlTabFromNav(
return false
}
-type DraftSqlTabLike = {
+type SqlTabLike = {
type?: string
metadata?: {
isDraft?: boolean
@@ -227,25 +236,33 @@ export function getDraftSqlTabSql({
return ''
}
-export function shouldConfirmCloseDraftSqlTab(tab: DraftSqlTabLike | undefined, sql: string) {
- if (tab?.type !== 'sql' || !tab.metadata?.isDraft || !tab.metadata.sqlId) return false
- return sql.trim().length > 0
-}
-
-export function countDraftSqlTabsRequiringCloseConfirmation(
- tabIds: string[],
- tabsMap: Record,
- getTabSql: (tabId: string) => string
+export function shouldConfirmCloseSqlTab(
+ tab: SqlTabLike | undefined,
+ sql: string,
+ savedSql?: string
) {
- return tabIds.filter((tabId) => shouldConfirmCloseDraftSqlTab(tabsMap[tabId], getTabSql(tabId)))
- .length
+ if (tab?.type !== 'sql' || !tab.metadata?.sqlId) return false
+ if (tab.metadata.isDraft) return sql.trim().length > 0
+ if (savedSql === undefined) return false
+ return sql !== savedSql
}
-export function getDiscardDraftSqlTabsDialogCopy(count: number) {
+export function countSqlTabsRequiringCloseConfirmation(
+ tabIds: string[],
+ tabsMap: Record,
+ getTabSql: (tabId: string) => string,
+ getSavedSql: (tabId: string) => string | undefined
+) {
+ return tabIds.filter((tabId) =>
+ shouldConfirmCloseSqlTab(tabsMap[tabId], getTabSql(tabId), getSavedSql(tabId))
+ ).length
+}
+
+export function getDiscardSqlTabsDialogCopy(count: number) {
if (count <= 1) {
return {
- title: 'Discard unsaved query?',
- description: 'This query has not been saved. Closing this tab will discard its contents.',
+ title: 'Discard unsaved changes?',
+ description: 'This query has unsaved changes. Closing this tab will discard them.',
confirmLabel: 'Discard query',
}
}
diff --git a/apps/studio/components/interfaces/SQLEditor/useCreateDraftSqlTab.ts b/apps/studio/components/interfaces/SQLEditor/useCreateDraftSqlTab.ts
index e8a015e0923..a2d42cd1890 100644
--- a/apps/studio/components/interfaces/SQLEditor/useCreateDraftSqlTab.ts
+++ b/apps/studio/components/interfaces/SQLEditor/useCreateDraftSqlTab.ts
@@ -1,10 +1,11 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
-import { useParams } from 'common'
+import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import { useRouter } from 'next/router'
import { useCallback } from 'react'
import { createDraftSqlTab } from './createDraftSqlTab'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
+import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useProfile } from '@/lib/profile'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
@@ -17,6 +18,10 @@ export function useCreateDraftSqlTab() {
const { data: project } = useSelectedProjectQuery()
const snapV2 = useSqlEditorV2StateSnapshot()
const tabs = useTabsStateSnapshot()
+ const [autoSaveSnippets] = useLocalStorageQuery(
+ LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
+ true
+ )
const { can: canCreateSQLSnippet } = useAsyncCheckPermissions(
PermissionAction.CREATE,
@@ -42,9 +47,10 @@ export function useCreateDraftSqlTab() {
tabs,
router,
initialSql: options?.initialSql,
+ creationMode: autoSaveSnippets ? 'saved' : 'draft',
})
},
- [canCreateSQLSnippet, profile, project, projectRef, router, snapV2, tabs]
+ [autoSaveSnippets, canCreateSQLSnippet, profile, project, projectRef, router, snapV2, tabs]
)
return { createDraftTab, canCreateSQLSnippet }
diff --git a/apps/studio/components/interfaces/SQLEditor/useDraftSqlTabCloseConfirmation.ts b/apps/studio/components/interfaces/SQLEditor/useDraftSqlTabCloseConfirmation.ts
index e33c7fd3cda..abb5086cc7c 100644
--- a/apps/studio/components/interfaces/SQLEditor/useDraftSqlTabCloseConfirmation.ts
+++ b/apps/studio/components/interfaces/SQLEditor/useDraftSqlTabCloseConfirmation.ts
@@ -2,8 +2,8 @@ import { useCallback, useMemo, useState } from 'react'
import {
clearPersistedDraftSqlTab,
- countDraftSqlTabsRequiringCloseConfirmation,
- getDiscardDraftSqlTabsDialogCopy,
+ countSqlTabsRequiringCloseConfirmation,
+ getDiscardSqlTabsDialogCopy,
getDraftSqlTabSql,
} from './createDraftSqlTab'
import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2'
@@ -15,7 +15,7 @@ type TabsSnapshot = {
type PendingClose = {
execute: () => void
- unsavedDraftTabCount: number
+ unsavedSqlTabCount: number
}
export function useDraftSqlTabCloseConfirmation({
@@ -42,7 +42,18 @@ export function useDraftSqlTabCloseConfirmation({
[projectRef, tabs.tabsMap]
)
- const cleanupDraftTabs = useCallback(
+ const getSavedSql = useCallback(
+ (tabId: string) => {
+ const tab = tabs.tabsMap[tabId]
+ const sqlId = tab?.metadata?.sqlId
+ if (!sqlId) return undefined
+
+ return getSqlEditorV2StateSnapshot().savedSql[sqlId]
+ },
+ [tabs.tabsMap]
+ )
+
+ const discardUnsavedSqlChanges = useCallback(
(tabIds: string[]) => {
if (!projectRef) return
@@ -50,10 +61,17 @@ export function useDraftSqlTabCloseConfirmation({
const tab = tabs.tabsMap[tabId]
const sqlId = tab?.metadata?.sqlId
- if (tab?.type !== 'sql' || !tab.metadata?.isDraft || !sqlId) continue
+ if (tab?.type !== 'sql' || !sqlId) continue
- clearPersistedDraftSqlTab(projectRef, sqlId)
- snapV2.removeSnippet(sqlId, true)
+ if (tab.metadata?.isDraft) {
+ clearPersistedDraftSqlTab(projectRef, sqlId)
+ snapV2.removeSnippet(sqlId, true)
+ } else {
+ const savedSql = getSqlEditorV2StateSnapshot().savedSql[sqlId]
+ if (savedSql !== undefined) {
+ snapV2.setSql({ id: sqlId, sql: savedSql, skipSave: true })
+ }
+ }
}
},
[projectRef, snapV2, tabs.tabsMap]
@@ -61,27 +79,28 @@ export function useDraftSqlTabCloseConfirmation({
const requestClose = useCallback(
(tabIds: string[], execute: () => void) => {
- const unsavedDraftTabCount = countDraftSqlTabsRequiringCloseConfirmation(
+ const unsavedSqlTabCount = countSqlTabsRequiringCloseConfirmation(
tabIds,
tabs.tabsMap,
- getTabSql
+ getTabSql,
+ getSavedSql
)
- if (unsavedDraftTabCount === 0) {
- cleanupDraftTabs(tabIds)
+ if (unsavedSqlTabCount === 0) {
+ discardUnsavedSqlChanges(tabIds)
execute()
return
}
setPendingClose({
- unsavedDraftTabCount,
+ unsavedSqlTabCount,
execute: () => {
- cleanupDraftTabs(tabIds)
+ discardUnsavedSqlChanges(tabIds)
execute()
},
})
},
- [cleanupDraftTabs, getTabSql, tabs.tabsMap]
+ [discardUnsavedSqlChanges, getSavedSql, getTabSql, tabs.tabsMap]
)
const requestCloseSingle = useCallback(
@@ -110,8 +129,8 @@ export function useDraftSqlTabCloseConfirmation({
)
const dialogCopy = useMemo(
- () => getDiscardDraftSqlTabsDialogCopy(pendingClose?.unsavedDraftTabCount ?? 0),
- [pendingClose?.unsavedDraftTabCount]
+ () => getDiscardSqlTabsDialogCopy(pendingClose?.unsavedSqlTabCount ?? 0),
+ [pendingClose?.unsavedSqlTabCount]
)
return {
diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx
index fc877c4a99b..368647a3ce3 100644
--- a/apps/studio/pages/project/[ref]/sql/[id].tsx
+++ b/apps/studio/pages/project/[ref]/sql/[id].tsx
@@ -1,4 +1,5 @@
import { usePrevious } from '@uidotdev/usehooks'
+import { LOCAL_STORAGE_KEYS } from 'common'
import { useParams } from 'common/hooks/useParams'
import Link from 'next/link'
import { useRouter } from 'next/router'
@@ -20,6 +21,7 @@ import SQLEditorLayout from '@/components/layouts/SQLEditorLayout/SQLEditorLayou
import { SQLEditorMenu } from '@/components/layouts/SQLEditorLayout/SQLEditorMenu'
import { useContentIdQuery } from '@/data/content/content-id-query'
import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory'
+import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { IS_PLATFORM } from '@/lib/constants'
import { useProfile } from '@/lib/profile'
@@ -38,6 +40,10 @@ const SqlEditor: NextPageWithLayout = () => {
const tabs = useTabsStateSnapshot()
const snapV2 = useSqlEditorV2StateSnapshot()
const { history, setLastVisitedSnippet } = useDashboardHistory()
+ const [autoSaveSnippets] = useLocalStorageQuery(
+ LOCAL_STORAGE_KEYS.SQL_EDITOR_AUTO_SAVE_SNIPPETS,
+ true
+ )
const allSnippets = useSnippets(ref!)
const snippet = allSnippets.find((x) => x.id === id)
@@ -124,11 +130,23 @@ const SqlEditor: NextPageWithLayout = () => {
snapV2,
tabs,
initialSql: typeof content === 'string' ? content : '',
+ creationMode: autoSaveSnippets ? 'saved' : 'draft',
skipNavigation: true,
})
router.replace(`/project/${ref}/sql/${draftId}`)
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [id, router.isReady, ref, project, profile, skip, content, allSnippets, history.sql])
+ }, [
+ id,
+ router.isReady,
+ ref,
+ project,
+ profile,
+ skip,
+ content,
+ allSnippets,
+ history.sql,
+ autoSaveSnippets,
+ ])
// Restore a draft opened directly by URL (e.g. on refresh) from local storage into the editor state
useEffect(() => {
diff --git a/apps/studio/state/sql-editor-v2.test.ts b/apps/studio/state/sql-editor-v2.test.ts
new file mode 100644
index 00000000000..b4cfbf92f4d
--- /dev/null
+++ b/apps/studio/state/sql-editor-v2.test.ts
@@ -0,0 +1,113 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { sqlEditorState } from './sql-editor-v2'
+import { readPersistedDraftSqlTab } from '@/components/interfaces/SQLEditor/draftSqlTabStorage.utils'
+import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
+
+const mocks = vi.hoisted(() => ({
+ upsertContent: vi.fn().mockResolvedValue(null),
+}))
+
+vi.mock('@/data/content/content-upsert-mutation', () => ({
+ upsertContent: mocks.upsertContent,
+}))
+
+vi.mock('@/data/query-client', () => ({
+ getQueryClient: () => ({
+ invalidateQueries: vi.fn(),
+ }),
+}))
+
+const PROJECT_REF = 'test-project'
+
+function resetSqlEditorState() {
+ sqlEditorState.folders = {}
+ sqlEditorState.snippets = {}
+ sqlEditorState.results = {}
+ sqlEditorState.explainResults = {}
+ sqlEditorState.savingStates = {}
+ sqlEditorState.savedSql = {}
+ sqlEditorState.lastUpdatedFolderName = ''
+ sqlEditorState.diffContent = undefined
+ sqlEditorState.needsSaving.clear()
+ sqlEditorState.saveSqlOnNextSave.clear()
+}
+
+function createSnippet(id: string, sql: string) {
+ const snippet = createSqlSnippetSkeletonV2({
+ idOverride: id,
+ name: 'Test snippet',
+ sql,
+ owner_id: 1,
+ project_id: 1,
+ })
+
+ snippet.isNotSavedInDatabaseYet = false
+ return snippet
+}
+
+describe('sqlEditorState manual SQL save mode', () => {
+ beforeEach(() => {
+ vi.useRealTimers()
+ localStorage.clear()
+ mocks.upsertContent.mockClear()
+ resetSqlEditorState()
+ })
+
+ it('updates saved snippet SQL locally without queueing a save', () => {
+ const snippet = createSnippet('saved-1', 'select 1')
+ sqlEditorState.addSnippet({ projectRef: PROJECT_REF, snippet })
+
+ sqlEditorState.setSql({ id: 'saved-1', sql: 'select 2' })
+
+ expect(sqlEditorState.snippets['saved-1'].snippet.content?.unchecked_sql).toBe('select 2')
+ expect(sqlEditorState.savedSql['saved-1']).toBe('select 1')
+ expect(sqlEditorState.needsSaving.has('saved-1')).toBe(false)
+ })
+
+ it('advances the saved SQL baseline after an explicit SQL save succeeds', async () => {
+ vi.useFakeTimers()
+ const snippet = createSnippet('saved-1', 'select 1')
+ sqlEditorState.addSnippet({ projectRef: PROJECT_REF, snippet })
+ sqlEditorState.setSql({ id: 'saved-1', sql: 'select 2' })
+
+ sqlEditorState.addNeedsSaving('saved-1', { saveSql: true })
+ await vi.runAllTimersAsync()
+
+ expect(mocks.upsertContent).toHaveBeenCalledWith({
+ projectRef: PROJECT_REF,
+ payload: expect.objectContaining({
+ id: 'saved-1',
+ content: expect.objectContaining({ unchecked_sql: 'select 2' }),
+ }),
+ })
+ expect(sqlEditorState.savedSql['saved-1']).toBe('select 2')
+ })
+
+ it('keeps the SQL save flag when an explicit SQL save fails so retry saves SQL content', async () => {
+ vi.useFakeTimers()
+ mocks.upsertContent.mockRejectedValueOnce(new Error('Failed to save'))
+ const snippet = createSnippet('saved-1', 'select 1')
+ sqlEditorState.addSnippet({ projectRef: PROJECT_REF, snippet })
+ sqlEditorState.setSql({ id: 'saved-1', sql: 'select 2' })
+
+ sqlEditorState.addNeedsSaving('saved-1', { saveSql: true })
+ await vi.runAllTimersAsync()
+
+ expect(sqlEditorState.savingStates['saved-1']).toBe('UPDATING_FAILED')
+ expect(sqlEditorState.saveSqlOnNextSave.get('saved-1')).toBe(true)
+ })
+
+ it('persists draft SQL to local storage without queueing a save', () => {
+ const snippet = createSnippet('draft-1', '')
+ snippet.isDraftTab = true
+ sqlEditorState.addSnippet({ projectRef: PROJECT_REF, snippet })
+
+ sqlEditorState.setSql({ id: 'draft-1', sql: 'select draft' })
+
+ expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toMatchObject({
+ sql: 'select draft',
+ })
+ expect(sqlEditorState.needsSaving.has('draft-1')).toBe(false)
+ })
+})
diff --git a/apps/studio/state/sql-editor-v2.ts b/apps/studio/state/sql-editor-v2.ts
index 10b1cf50611..ab9e39479bc 100644
--- a/apps/studio/state/sql-editor-v2.ts
+++ b/apps/studio/state/sql-editor-v2.ts
@@ -46,6 +46,10 @@ function isDraftSqlTabId(id: string) {
return sqlEditorState.snippets[id]?.snippet?.isDraftTab === true
}
+function getSnippetSql(snippet: SnippetWithContent | undefined) {
+ return (snippet?.content?.unchecked_sql ?? '') as string
+}
+
export const sqlEditorState = proxy({
// ========================================================================
// ## Data properties within the store
@@ -98,6 +102,16 @@ export const sqlEditorState = proxy({
savingStates: {} as {
[snippetId: string]: 'IDLE' | 'UPDATING' | 'UPDATING_FAILED'
},
+ /**
+ * Last SQL content known to be persisted. Used to keep editor SQL changes manual-save only.
+ */
+ savedSql: {} as {
+ [snippetId: string]: string | undefined
+ },
+ /**
+ * Marks saves that should persist the current editor SQL instead of only snippet metadata.
+ */
+ saveSqlOnNextSave: proxyMap([]),
/**
* UI-imposed limit for the number of results a query can return (applied to the SQL query being run if applicable).
* Acts as a safeguard to prevent accidentally taking down the database from a really large SELECT query.
@@ -136,6 +150,10 @@ export const sqlEditorState = proxy({
sqlEditorState.results[snippet.id] = []
sqlEditorState.explainResults[snippet.id] = { rows: [] }
sqlEditorState.savingStates[snippet.id] = 'IDLE'
+
+ if (snippet.content && !snippet.isDraftTab) {
+ sqlEditorState.savedSql[snippet.id] = getSnippetSql(snippet)
+ }
},
/**
@@ -169,6 +187,7 @@ export const sqlEditorState = proxy({
if (storedSnippet) {
if (!storedSnippet.snippet.content) {
storedSnippet.snippet.content = snippet.content
+ sqlEditorState.savedSql[snippet.id] = getSnippetSql(snippet)
}
} else {
sqlEditorState.addSnippet({ projectRef: projectRef, snippet })
@@ -176,14 +195,12 @@ export const sqlEditorState = proxy({
},
/**
- * Update the snippet content of a snippet and queue for sync saving
- * Possibly can consolidate with `updateSnippet` to simplify
+ * Update the snippet content in local editor state. Saved snippets are persisted only when the
+ * user explicitly saves; drafts continue to persist their local draft content.
*/
setSql: ({
id,
sql,
- shouldInvalidate = false,
- skipSave = false,
}: {
id: string
sql: string
@@ -203,11 +220,13 @@ export const sqlEditorState = proxy({
}
return
}
-
- if (!skipSave) sqlEditorState.needsSaving.set(id, shouldInvalidate)
}
},
+ setSavedSql: (id: string, sql?: string) => {
+ sqlEditorState.savedSql[id] = sql ?? getSnippetSql(sqlEditorState.snippets[id]?.snippet)
+ },
+
/**
* Update snippet in Valtio store after renaming
* Renaming a snippet follows an async saving and hence doesnt require queuing for sync saving here
@@ -242,6 +261,9 @@ export const sqlEditorState = proxy({
const { [id]: explainResult, ...otherExplainResults } = sqlEditorState.explainResults
sqlEditorState.explainResults = otherExplainResults
+ delete sqlEditorState.savedSql[id]
+ sqlEditorState.saveSqlOnNextSave.delete(id)
+
if (!skipSave) sqlEditorState.needsSaving.delete(id)
},
@@ -319,8 +341,9 @@ export const sqlEditorState = proxy({
*/
setLimit: (value: number) => (sqlEditorState.limit = value),
- addNeedsSaving: (id: string) => {
+ addNeedsSaving: (id: string, options?: { saveSql?: boolean }) => {
if (isDraftSqlTabId(id)) return
+ if (options?.saveSql) sqlEditorState.saveSqlOnNextSave.set(id, true)
sqlEditorState.needsSaving.set(id, true)
},
@@ -447,8 +470,15 @@ async function upsertSnippet(
if (snippet?.content && 'isNotSavedInDatabaseYet' in snippet) {
snippet.isNotSavedInDatabaseYet = false
}
+ const savedPayloadSql = (payload.content as Partial).unchecked_sql
+ if (savedPayloadSql !== undefined) {
+ sqlEditorState.savedSql[id] = savedPayloadSql
+ }
sqlEditorState.savingStates[id] = 'IDLE'
} catch (error) {
+ if ((payload.content as Partial).unchecked_sql !== undefined) {
+ sqlEditorState.saveSqlOnNextSave.set(id, true)
+ }
sqlEditorState.savingStates[id] = 'UPDATING_FAILED'
}
}
@@ -524,6 +554,9 @@ if (typeof window !== 'undefined') {
content,
favorite,
} = snippet.snippet
+ const shouldSaveSql =
+ state.saveSqlOnNextSave.get(id) === true || snippet.snippet.isNotSavedInDatabaseYet
+ const savedSql = state.savedSql[id]
if (visibility === 'project' && !!folder_id) {
toast.error('Shared snippet cannot be within a folder')
@@ -542,13 +575,17 @@ if (typeof window !== 'undefined') {
folder_id: folder_id ?? undefined,
favorite: favorite ?? false,
content: {
- ...content!,
+ ...(content as SqlSnippets.Content),
+ unchecked_sql: shouldSaveSql
+ ? content?.unchecked_sql
+ : (savedSql ?? content?.unchecked_sql),
content_id: id,
- },
+ } as SqlSnippets.Content,
},
shouldInvalidate
)
sqlEditorState.needsSaving.delete(id)
+ sqlEditorState.saveSqlOnNextSave.delete(id)
}
} else if (folder) {
upsertFolder(id, folder.projectRef, folder.folder.name)
diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts
index d94483b92e0..f8af3ac1165 100644
--- a/packages/common/constants/local-storage.ts
+++ b/packages/common/constants/local-storage.ts
@@ -45,6 +45,7 @@ export const LOCAL_STORAGE_KEYS = {
SQL_EDITOR_AI_SCHEMA: (ref: string) => `supabase_sql-editor-ai-schema-enabled-${ref}`,
SQL_EDITOR_AI_OPEN: 'supabase_sql-editor-ai-open',
SQL_EDITOR_LAST_SELECTED_DB: (ref: string) => `sql-editor-last-selected-db-${ref}`,
+ SQL_EDITOR_AUTO_SAVE_SNIPPETS: 'supabase_sql-editor-auto-save-snippets',
// Stores unsaved/untitled SQL editor draft tabs (sql + name) so they survive reloads until explicitly saved
SQL_EDITOR_DRAFT_TABS: (ref: string) => `sql-editor-draft-tabs-${ref}`,
SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`,