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 = ({