From f86e37af232a1e1f017c2cd263c2733498df247a Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 3 Jun 2026 15:51:49 +1000 Subject: [PATCH] tab states --- .../interfaces/App/RouteValidationWrapper.tsx | 2 +- .../interfaces/SQLEditor/MonacoEditor.tsx | 5 +- .../interfaces/SQLEditor/SQLEditor.tsx | 46 ++-- .../SQLEditor/UtilityPanel/UtilityPanel.tsx | 20 +- .../interfaces/SQLEditor/createDraftSqlTab.ts | 208 ++++++++++++++++++ .../draftSqlTabStorage.utils.test.ts | 72 ++++++ .../SQLEditor/draftSqlTabStorage.utils.ts | 94 ++++++++ .../SQLEditor/saveSqlSnippet.utils.test.ts | 25 +++ .../SQLEditor/saveSqlSnippet.utils.ts | 15 +- .../SQLEditor/useCreateDraftSqlTab.ts | 53 +++++ .../useRestorePersistedDraftSqlTabs.ts | 37 ++++ .../interfaces/SQLEditor/useSaveSqlSnippet.ts | 30 +-- .../SQLEditorLayout/SQLEditorLayout.tsx | 3 + .../SQLEditorNavV2/SQLEditorNav.tsx | 54 +++-- .../useSqlEditorCreateActions.ts | 14 +- .../components/layouts/Tabs/SortableTab.tsx | 4 +- .../components/layouts/Tabs/TabPreview.tsx | 2 +- apps/studio/components/layouts/Tabs/Tabs.tsx | 136 +++++------- apps/studio/pages/project/[ref]/sql/[id].tsx | 146 +++++++----- apps/studio/pages/project/[ref]/sql/index.tsx | 2 +- apps/studio/state/sql-editor-v2.ts | 28 ++- apps/studio/state/tabs.tsx | 20 +- packages/common/constants/local-storage.ts | 1 + 23 files changed, 807 insertions(+), 210 deletions(-) create mode 100644 apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.ts create mode 100644 apps/studio/components/interfaces/SQLEditor/draftSqlTabStorage.utils.test.ts create mode 100644 apps/studio/components/interfaces/SQLEditor/draftSqlTabStorage.utils.ts create mode 100644 apps/studio/components/interfaces/SQLEditor/useCreateDraftSqlTab.ts create mode 100644 apps/studio/components/interfaces/SQLEditor/useRestorePersistedDraftSqlTabs.ts diff --git a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx index 51e218b8737..87540779a8a 100644 --- a/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx +++ b/apps/studio/components/interfaces/App/RouteValidationWrapper.tsx @@ -97,7 +97,7 @@ export const RouteValidationWrapper = ({ children }: PropsWithChildren<{}>) => { if (ref !== undefined && id !== undefined) { if (router.pathname.endsWith('/sql/[id]') && id !== 'new') { const snippet = getSqlEditorV2StateSnapshot().snippets[id]?.snippet - if (!snippet?.isNotSavedInDatabaseYet) { + if (!snippet?.isDraftTab && !snippet?.isNotSavedInDatabaseYet) { setLastVisitedSnippet(id) } } else if (router.pathname.endsWith('/editor/[id]')) { diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx index 81ba4cb1308..1f318094f38 100644 --- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx @@ -214,8 +214,11 @@ const MonacoEditor = ({ function handleEditorChange(value: string | undefined) { tabsSnap.makeActiveTabPermanent() - if (id && value) { + if (id && value !== undefined) { setValue(value) + if (snippet) { + snapV2.setSql({ id, sql: value, skipSave: true }) + } } } diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 1cd290b56b8..422f1553fbe 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -58,6 +58,7 @@ import { import { SqlEditorPanelHeader } from './SqlEditorPanelHeader' import { SqlEditorShowSqlToggle } from './SqlEditorShowSqlToggle' import { getSnippetSqlFromContent } from './sqlSnippet.utils' +import { useCreateDraftSqlTab } from './useCreateDraftSqlTab' import { useSaveSqlSnippet } from './useSaveSqlSnippet' import { useSqlEditorCompletion } from './useSqlEditorCompletion' import { UtilityActions } from './UtilityPanel/UtilityActions' @@ -85,7 +86,6 @@ import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' import { isError } from '@/data/utils/error-check' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' -import { generateUuid } from '@/lib/api/snippets.browser' import { BASE_PATH } from '@/lib/constants' import { formatSql } from '@/lib/formatSql' import { detectOS } from '@/lib/helpers' @@ -171,13 +171,11 @@ export const SQLEditor = () => { registerInCommandMenu: true, }) + const { createDraftTab } = useCreateDraftSqlTab() + const openNewSnippet = useCallback(() => { - if (!ref) return - // skip=true bypasses the "load last visited snippet" redirect on /sql/new. - // Without it, the effect in pages/project/[ref]/sql/[id].tsx bounces back - // to the previous snippet. - router.push(`/project/${ref}/sql/new?skip=true`) - }, [ref, router]) + createDraftTab() + }, [createDraftTab]) useShortcut(SHORTCUT_IDS.SQL_EDITOR_NEW_SNIPPET, openNewSnippet, { registerInCommandMenu: true, @@ -194,27 +192,22 @@ export const SQLEditor = () => { refocusEditor() }, [refocusEditor]) - // generate a new snippet title and an id to be used for new snippets. The dependency on urlId is to avoid a bug which - // shows up when clicking on the SQL Editor while being in the SQL editor on a random snippet. - const [generatedNewSnippetName, generatedId] = useMemo(() => { - const name = generateSnippetTitle() - return [name, generateUuid([`${name}.sql`])] - }, [urlId]) - - // the id is stable across renders - it depends either on the url or on the memoized generated id const effectiveUrlId = notebookEditorContext?.blockId ?? urlId - const id = !effectiveUrlId || effectiveUrlId === 'new' ? generatedId : effectiveUrlId + const id = effectiveUrlId === 'new' ? '' : (effectiveUrlId ?? '') + + if (!id) { + return null + } const limit = snapV2.limit - const results = snapV2.results[id]?.[0] + const results = id ? snapV2.results[id]?.[0] : undefined const snippetIsLoading = !( - id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined + id && + id in snapV2.snippets && + snapV2.snippets[id].snippet.content !== undefined ) - const isLoading = effectiveUrlId === 'new' ? false : snippetIsLoading - const snippetDisplayTitle = - effectiveUrlId === 'new' - ? generatedNewSnippetName - : (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName) + const isLoading = !id || snippetIsLoading + const snippetDisplayTitle = snapV2.snippets[id]?.snippet.name ?? generateSnippetTitle() useSqlEditorCompletion(id, monacoRef.current) @@ -378,7 +371,6 @@ export const SQLEditor = () => { id, snippetName: snippetDisplayTitle, getEditorSql, - isOnNewRoute: urlId === 'new', }) useShortcut(SHORTCUT_IDS.SQL_EDITOR_SAVE, () => void saveQuery(), { @@ -1103,11 +1095,7 @@ export const SQLEditor = () => { : '' } id={id} - snippetName={ - urlId === 'new' - ? generatedNewSnippetName - : (snapV2.snippets[id]?.snippet.name ?? generatedNewSnippetName) - } + snippetName={snippetDisplayTitle} className={cn(isDiffOpen && 'hidden')} editorRef={editorRef} monacoRef={monacoRef} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx index bf86557acd1..0614e2918a0 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel.tsx @@ -6,6 +6,7 @@ import { Popover, PopoverContent, PopoverTrigger, ToggleGroup, ToggleGroupItem } import { ChartConfig, ChartSettings } from './ChartConfig' import { UtilityTabLogsResults } from './UtilityTabLogsResults' import { UtilityTabResults } from './UtilityTabResults' +import { isDraftSqlSnippet } from '@/components/interfaces/SQLEditor/createDraftSqlTab' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { DownloadResultsButton } from '@/components/ui/DownloadResultsButton' import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation' @@ -76,7 +77,11 @@ export const UtilityPanel = ({ }, } - snapV2.updateSnippet({ id, snippet: newSnippet as unknown as Snippet }) + snapV2.updateSnippet({ + id, + snippet: newSnippet as unknown as Snippet, + skipSave: isDraftSqlSnippet(snippet), + }) }, onError: async (_err, _newContent, _context) => { toast.error(`Failed to update chart. Please try again.`) @@ -122,6 +127,19 @@ export const UtilityPanel = ({ } if (!ref || !snippet?.id) return + if (isDraftSqlSnippet(snippet)) { + snapV2.updateSnippet({ + id, + snippet: { + content: { + ...snippet.content, + chart: config, + }, + } as unknown as Snippet, + skipSave: true, + }) + return + } upsertContent({ projectRef: ref, diff --git a/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.ts b/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.ts new file mode 100644 index 00000000000..fbde476d0af --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/createDraftSqlTab.ts @@ -0,0 +1,208 @@ +import type { NextRouter } from 'next/router' + +import { + persistDraftSqlTab, + readPersistedDraftSqlTab, + removePersistedDraftSqlTab, +} from './draftSqlTabStorage.utils' +import { generateSnippetTitle } from './SQLEditor.constants' +import { createSqlSnippetSkeletonV2 } from './SQLEditor.utils' +import { generateUuid } from '@/lib/api/snippets.browser' +import type { QueryExecutionSource } from '@/state/query-execution-source' +import type { SnippetWithContent } from '@/state/sql-editor-v2' +import { createTabId } from '@/state/tabs' + +type SqlEditorV2Actions = { + addSnippet: (args: { projectRef: string; snippet: SnippetWithContent }) => void +} + +type TabsActions = { + addTab: (tab: { + id: string + type: 'sql' + label?: string + metadata?: { + sqlId?: string + name?: string + isDraft?: boolean + } + isPreview?: boolean + }) => void +} + +export type CreateDraftSqlTabParams = { + projectRef: string + projectId: number + ownerId: number + snapV2: SqlEditorV2Actions + tabs: TabsActions + router?: NextRouter + initialSql?: string + querySource?: QueryExecutionSource + /** When true, only registers state without changing the route */ + skipNavigation?: boolean +} + +export function createDraftSqlTab({ + projectRef, + projectId, + ownerId, + snapV2, + tabs, + router, + initialSql = '', + querySource = 'database', + skipNavigation = false, +}: CreateDraftSqlTabParams): string { + const name = generateSnippetTitle() + const draftId = generateUuid([`${name}.sql`, Date.now().toString()]) + + const snippet = createSqlSnippetSkeletonV2({ + idOverride: draftId, + name, + sql: initialSql, + owner_id: ownerId, + project_id: projectId, + querySource, + }) + + snippet.isDraftTab = true + + snapV2.addSnippet({ projectRef, snippet }) + + persistDraftSqlTab(projectRef, draftId, { + sql: initialSql, + name, + querySource, + }) + + tabs.addTab({ + id: createTabId('sql', { id: draftId }), + type: 'sql', + label: name, + metadata: { + sqlId: draftId, + name, + isDraft: true, + }, + }) + + if (!skipNavigation && router) { + void router.push(`/project/${projectRef}/sql/${draftId}`) + } + + return draftId +} + +export function restoreDraftSqlTab({ + draftId, + projectRef, + projectId, + ownerId, + snapV2, + name = generateSnippetTitle(), + initialSql = '', + querySource = 'database', +}: Omit & { + draftId: string + name?: string + initialSql?: string +}) { + const persisted = readPersistedDraftSqlTab(projectRef, draftId) + const resolvedName = persisted?.name ?? name + const resolvedSql = persisted?.sql ?? initialSql + const resolvedQuerySource = persisted?.querySource ?? querySource + + const snippet = createSqlSnippetSkeletonV2({ + idOverride: draftId, + name: resolvedName, + sql: resolvedSql, + owner_id: ownerId, + project_id: projectId, + querySource: resolvedQuerySource, + }) + + snippet.isDraftTab = true + snapV2.addSnippet({ projectRef, snippet }) +} + +export function clearPersistedDraftSqlTab(projectRef: string, draftId: string) { + removePersistedDraftSqlTab(projectRef, draftId) +} + +export function restoreOpenDraftSqlTabs({ + projectRef, + projectId, + ownerId, + snapV2, + openTabs, + tabsMap, + existingSnippetIds, +}: { + projectRef: string + projectId: number + ownerId: number + snapV2: SqlEditorV2Actions + openTabs: string[] + tabsMap: Record + existingSnippetIds: Set +}) { + const openDraftIds: string[] = [] + + for (const tabId of openTabs) { + const tab = tabsMap[tabId] + if (tab?.type !== 'sql' || !tab.metadata?.isDraft || !tab.metadata.sqlId) { + continue + } + + openDraftIds.push(tab.metadata.sqlId) + + if (existingSnippetIds.has(tab.metadata.sqlId)) { + continue + } + + restoreDraftSqlTab({ + draftId: tab.metadata.sqlId, + projectRef, + projectId, + ownerId, + snapV2, + name: tab.metadata.name, + }) + } + + return openDraftIds +} + +export function isDraftSqlSnippet( + snippet: Pick | undefined +) { + return snippet?.isDraftTab === true +} + +export function getOpenDraftSqlTabIds( + openTabs: string[], + tabsMap: Record +) { + const ids = new Set() + + for (const tabId of openTabs) { + const tab = tabsMap[tabId] + if (tab?.type === 'sql' && tab.metadata?.isDraft && tab.metadata.sqlId) { + ids.add(tab.metadata.sqlId) + } + } + + return ids +} + +export function shouldHideDraftSqlTabFromNav( + snippetId: string, + openDraftIds: Set, + snippet?: Pick +) { + if (openDraftIds.has(snippetId)) return true + if (isDraftSqlSnippet(snippet)) return true + if (snippet?.isNotSavedInDatabaseYet) return true + return false +} diff --git a/apps/studio/components/interfaces/SQLEditor/draftSqlTabStorage.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/draftSqlTabStorage.utils.test.ts new file mode 100644 index 00000000000..2a9f5959a45 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/draftSqlTabStorage.utils.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + getDraftSqlTabStorageKey, + persistDraftSqlTab, + prunePersistedDraftSqlTabs, + readPersistedDraftSqlTab, + removePersistedDraftSqlTab, +} from './draftSqlTabStorage.utils' + +const PROJECT_REF = 'test-project' + +describe('draftSqlTabStorage', () => { + beforeEach(() => { + localStorage.clear() + vi.stubGlobal('window', globalThis.window) + }) + + it('persists and reads draft tab sql', () => { + persistDraftSqlTab(PROJECT_REF, 'draft-1', { + sql: 'select 1', + name: 'Untitled query', + }) + + expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toMatchObject({ + sql: 'select 1', + name: 'Untitled query', + querySource: 'database', + }) + }) + + it('merges partial updates without clearing sql', () => { + persistDraftSqlTab(PROJECT_REF, 'draft-1', { + sql: 'select 1', + name: 'Untitled query', + }) + + persistDraftSqlTab(PROJECT_REF, 'draft-1', { + name: 'Untitled query 2', + }) + + expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toMatchObject({ + sql: 'select 1', + name: 'Untitled query 2', + }) + }) + + it('removes a persisted draft tab', () => { + persistDraftSqlTab(PROJECT_REF, 'draft-1', { + sql: 'select 1', + name: 'Untitled query', + }) + + removePersistedDraftSqlTab(PROJECT_REF, 'draft-1') + + expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toBeUndefined() + }) + + it('prunes drafts that are no longer open', () => { + persistDraftSqlTab(PROJECT_REF, 'draft-1', { sql: 'select 1', name: 'One' }) + persistDraftSqlTab(PROJECT_REF, 'draft-2', { sql: 'select 2', name: 'Two' }) + + prunePersistedDraftSqlTabs(PROJECT_REF, ['draft-2']) + + expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-1')).toBeUndefined() + expect(readPersistedDraftSqlTab(PROJECT_REF, 'draft-2')).toBeDefined() + }) + + it('uses a project-scoped storage key', () => { + expect(getDraftSqlTabStorageKey(PROJECT_REF)).toBe(`sql-editor-draft-tabs-${PROJECT_REF}`) + }) +}) diff --git a/apps/studio/components/interfaces/SQLEditor/draftSqlTabStorage.utils.ts b/apps/studio/components/interfaces/SQLEditor/draftSqlTabStorage.utils.ts new file mode 100644 index 00000000000..4b96d21a975 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/draftSqlTabStorage.utils.ts @@ -0,0 +1,94 @@ +import { LOCAL_STORAGE_KEYS } from 'common' + +import type { QueryExecutionSource } from '@/state/query-execution-source' + +export type PersistedDraftSqlTab = { + sql: string + name: string + querySource: QueryExecutionSource + updatedAt: number +} + +export type DraftSqlTabStorage = Record + +export function getDraftSqlTabStorageKey(projectRef: string) { + return LOCAL_STORAGE_KEYS.SQL_EDITOR_DRAFT_TABS(projectRef) +} + +export function readDraftSqlTabStorage(projectRef: string): DraftSqlTabStorage { + if (typeof window === 'undefined' || !projectRef) return {} + + try { + const raw = localStorage.getItem(getDraftSqlTabStorageKey(projectRef)) + if (!raw) return {} + + const parsed = JSON.parse(raw) as unknown + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return {} + } + + return parsed as DraftSqlTabStorage + } catch { + return {} + } +} + +export function readPersistedDraftSqlTab( + projectRef: string, + draftId: string +): PersistedDraftSqlTab | undefined { + return readDraftSqlTabStorage(projectRef)[draftId] +} + +export function persistDraftSqlTab( + projectRef: string, + draftId: string, + patch: { + sql?: string + name: string + querySource?: QueryExecutionSource + } +) { + if (typeof window === 'undefined' || !projectRef) return + + const storage = readDraftSqlTabStorage(projectRef) + const existing = storage[draftId] + + storage[draftId] = { + sql: patch.sql ?? existing?.sql ?? '', + name: patch.name, + querySource: patch.querySource ?? existing?.querySource ?? 'database', + updatedAt: Date.now(), + } + + localStorage.setItem(getDraftSqlTabStorageKey(projectRef), JSON.stringify(storage)) +} + +export function removePersistedDraftSqlTab(projectRef: string, draftId: string) { + if (typeof window === 'undefined' || !projectRef) return + + const storage = readDraftSqlTabStorage(projectRef) + if (!(draftId in storage)) return + + delete storage[draftId] + localStorage.setItem(getDraftSqlTabStorageKey(projectRef), JSON.stringify(storage)) +} + +export function prunePersistedDraftSqlTabs(projectRef: string, openDraftIds: string[]) { + if (typeof window === 'undefined' || !projectRef) return + + const storage = readDraftSqlTabStorage(projectRef) + const openDraftIdSet = new Set(openDraftIds) + let hasChanges = false + + for (const draftId of Object.keys(storage)) { + if (!openDraftIdSet.has(draftId)) { + delete storage[draftId] + hasChanges = true + } + } + + if (hasChanges) { + localStorage.setItem(getDraftSqlTabStorageKey(projectRef), JSON.stringify(storage)) + } +} diff --git a/apps/studio/components/interfaces/SQLEditor/saveSqlSnippet.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/saveSqlSnippet.utils.test.ts index a85f6cbdb13..ec52f45d613 100644 --- a/apps/studio/components/interfaces/SQLEditor/saveSqlSnippet.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/saveSqlSnippet.utils.test.ts @@ -99,6 +99,31 @@ describe('saveSqlSnippet', () => { expect(onTabLabelUpdate).toHaveBeenCalledWith('Active users') }) + it('clears draft tab flag when saving a draft snippet', async () => { + const updateSnippet = vi.fn() + const onDraftSaved = vi.fn() + + await saveSqlSnippet({ + ...baseParams, + updateSnippet, + onDraftSaved, + snippet: { + id: 'snippet-id', + name: untitledSnippetTitle, + visibility: 'user', + isNotSavedInDatabaseYet: true, + isDraftTab: true, + } as any, + }) + + expect(updateSnippet).toHaveBeenCalledWith({ + id: 'snippet-id', + snippet: { isDraftTab: false }, + skipSave: true, + }) + expect(onDraftSaved).toHaveBeenCalled() + }) + it('skips AI rename when snippet already has a custom name', async () => { const generateSqlTitle = vi.fn() diff --git a/apps/studio/components/interfaces/SQLEditor/saveSqlSnippet.utils.ts b/apps/studio/components/interfaces/SQLEditor/saveSqlSnippet.utils.ts index 9689867989f..ce159b133c3 100644 --- a/apps/studio/components/interfaces/SQLEditor/saveSqlSnippet.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/saveSqlSnippet.utils.ts @@ -23,8 +23,13 @@ export type SaveSqlSnippetParams = { }) => void addNeedsSaving: (id: string) => void generateSqlTitle: (args: { sql: string }) => Promise<{ title: string }> - updateSnippet: (args: { id: string; snippet: { name: string } }) => void + updateSnippet: (args: { + id: string + snippet: { name?: string; isDraftTab?: boolean } + skipSave?: boolean + }) => void onTabLabelUpdate?: (name: string) => void + onDraftSaved?: () => void onNavigateToSnippet?: () => void } @@ -44,6 +49,7 @@ export async function saveSqlSnippet({ generateSqlTitle, updateSnippet, onTabLabelUpdate, + onDraftSaved, onNavigateToSnippet, }: SaveSqlSnippetParams): Promise<{ saved: boolean; reason?: 'empty' }> { const trimmedSql = sql.trim() @@ -68,6 +74,13 @@ export async function saveSqlSnippet({ } const shouldInvalidate = currentSnippet.isNotSavedInDatabaseYet + + if (currentSnippet.isDraftTab) { + updateSnippet({ id, snippet: { isDraftTab: false }, skipSave: true }) + currentSnippet = { ...currentSnippet, isDraftTab: false } + onDraftSaved?.() + } + setSql({ id, sql: trimmedSql, shouldInvalidate }) addNeedsSaving(id) diff --git a/apps/studio/components/interfaces/SQLEditor/useCreateDraftSqlTab.ts b/apps/studio/components/interfaces/SQLEditor/useCreateDraftSqlTab.ts new file mode 100644 index 00000000000..c5bc577c5f9 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useCreateDraftSqlTab.ts @@ -0,0 +1,53 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' +import { useRouter } from 'next/router' +import { useCallback } from 'react' + +import { createDraftSqlTab } from './createDraftSqlTab' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useProfile } from '@/lib/profile' +import type { QueryExecutionSource } from '@/state/query-execution-source' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useTabsStateSnapshot } from '@/state/tabs' + +export function useCreateDraftSqlTab() { + const router = useRouter() + const { ref: projectRef } = useParams() + const { profile } = useProfile() + const { data: project } = useSelectedProjectQuery() + const snapV2 = useSqlEditorV2StateSnapshot() + const tabs = useTabsStateSnapshot() + + const { can: canCreateSQLSnippet } = useAsyncCheckPermissions( + PermissionAction.CREATE, + 'user_content', + { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + } + ) + + const createDraftTab = useCallback( + (options?: { initialSql?: string; querySource?: QueryExecutionSource }) => { + if (!projectRef) return console.error('Project ref is required') + if (!project) return console.error('Project is required') + if (!profile) return console.error('Profile is required') + if (!canCreateSQLSnippet) return + + return createDraftSqlTab({ + projectRef, + projectId: project.id, + ownerId: profile.id, + snapV2, + tabs, + router, + initialSql: options?.initialSql, + querySource: options?.querySource, + }) + }, + [canCreateSQLSnippet, profile, project, projectRef, router, snapV2, tabs] + ) + + return { createDraftTab, canCreateSQLSnippet } +} diff --git a/apps/studio/components/interfaces/SQLEditor/useRestorePersistedDraftSqlTabs.ts b/apps/studio/components/interfaces/SQLEditor/useRestorePersistedDraftSqlTabs.ts new file mode 100644 index 00000000000..c81cafb7a89 --- /dev/null +++ b/apps/studio/components/interfaces/SQLEditor/useRestorePersistedDraftSqlTabs.ts @@ -0,0 +1,37 @@ +import { useParams } from 'common' +import { useEffect, useRef } from 'react' + +import { restoreOpenDraftSqlTabs } from './createDraftSqlTab' +import { prunePersistedDraftSqlTabs } from './draftSqlTabStorage.utils' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useProfile } from '@/lib/profile' +import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useTabsStateSnapshot } from '@/state/tabs' + +export function useRestorePersistedDraftSqlTabs() { + const { ref: projectRef } = useParams() + const { profile } = useProfile() + const { data: project } = useSelectedProjectQuery() + const snapV2 = useSqlEditorV2StateSnapshot() + const tabs = useTabsStateSnapshot() + const hasRestoredRef = useRef(null) + + useEffect(() => { + if (!projectRef || !project || !profile) return + if (hasRestoredRef.current === projectRef) return + + hasRestoredRef.current = projectRef + + const openDraftIds = restoreOpenDraftSqlTabs({ + projectRef, + projectId: project.id, + ownerId: profile.id, + snapV2, + openTabs: tabs.openTabs, + tabsMap: tabs.tabsMap, + existingSnippetIds: new Set(Object.keys(getSqlEditorV2StateSnapshot().snippets)), + }) + + prunePersistedDraftSqlTabs(projectRef, openDraftIds) + }, [profile, project, projectRef, snapV2, tabs.openTabs, tabs.tabsMap]) +} diff --git a/apps/studio/components/interfaces/SQLEditor/useSaveSqlSnippet.ts b/apps/studio/components/interfaces/SQLEditor/useSaveSqlSnippet.ts index af4402c0ca1..07030c0be80 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSaveSqlSnippet.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSaveSqlSnippet.ts @@ -1,9 +1,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' -import { useRouter } from 'next/router' import { useCallback } from 'react' import { toast } from 'sonner' +import { clearPersistedDraftSqlTab } from './createDraftSqlTab' import { saveSqlSnippet } from './saveSqlSnippet.utils' import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -18,16 +18,9 @@ export type UseSaveSqlSnippetOptions = { id: string snippetName: string getEditorSql: () => string | undefined - isOnNewRoute?: boolean } -export function useSaveSqlSnippet({ - id, - snippetName, - getEditorSql, - isOnNewRoute = false, -}: UseSaveSqlSnippetOptions) { - const router = useRouter() +export function useSaveSqlSnippet({ id, snippetName, getEditorSql }: UseSaveSqlSnippetOptions) { const { ref: projectRef } = useParams() const { profile } = useProfile() const { data: project } = useSelectedProjectQuery() @@ -52,9 +45,8 @@ export function useSaveSqlSnippet({ if (!profile) return console.error('Profile is required') const state = getSqlEditorV2StateSnapshot() - const snippet = state.snippets[id] - const isReadOnly = - snippet?.snippet.visibility === 'project' && snippet?.snippet.owner_id !== profile.id + const snippet = state.snippets[id]?.snippet + const isReadOnly = snippet?.visibility === 'project' && snippet?.owner_id !== profile.id if (isReadOnly) return @@ -82,11 +74,13 @@ export function useSaveSqlSnippet({ const tabId = createTabId('sql', { id }) tabs.updateTab(tabId, { label: name }) }, - onNavigateToSnippet: isOnNewRoute - ? () => { - void router.push(`/project/${projectRef}/sql/${id}`, undefined, { shallow: true }) - } - : undefined, + onDraftSaved: () => { + const tabId = createTabId('sql', { id }) + tabs.updateTab(tabId, { metadata: { isDraft: false } }) + if (projectRef) { + clearPersistedDraftSqlTab(projectRef, id) + } + }, }) if (result.reason === 'empty') { @@ -98,12 +92,10 @@ export function useSaveSqlSnippet({ getEditorSql, id, isHipaaProjectDisallowed, - isOnNewRoute, profile, project, projectRef, querySourceState.executionSource, - router, snippetName, snapV2.addNeedsSaving, snapV2.addSnippet, diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx index 6ce9d4ddad2..db31a8ca514 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorLayout.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react' import { OngoingQueriesPanel } from '@/components/interfaces/SQLEditor/OngoingQueriesPanel' +import { useRestorePersistedDraftSqlTabs } from '@/components/interfaces/SQLEditor/useRestorePersistedDraftSqlTabs' import { withAuth } from '@/hooks/misc/withAuth' export interface SQLEditorLayoutProps { @@ -8,6 +9,8 @@ export interface SQLEditorLayoutProps { } const SQLEditorLayout = ({ children }: SQLEditorLayoutProps) => { + useRestorePersistedDraftSqlTabs() + return ( <> {children} diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 78821d14781..f1a031b1d5f 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -2,7 +2,7 @@ import { keepPreviousData } from '@tanstack/react-query' import { IS_PLATFORM, LOCAL_STORAGE_KEYS, useParams } from 'common' import { Heart } from 'lucide-react' import { useRouter } from 'next/router' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { InnerSideBarEmptyPanel, @@ -29,6 +29,10 @@ import { SqlEditorNavFolder } from './SqlEditorNavFolder' import { SQLEditorSectionActions } from './SQLEditorSectionActions' import { UnshareSnippetModal } from './UnshareSnippetModal' import { useSqlEditorCreateActions } from './useSqlEditorCreateActions' +import { + getOpenDraftSqlTabIds, + shouldHideDraftSqlTabFromNav, +} from '@/components/interfaces/SQLEditor/createDraftSqlTab' import { DownloadSnippetModal } from '@/components/interfaces/SQLEditor/DownloadSnippetModal' import { MoveQueryModal } from '@/components/interfaces/SQLEditor/MoveQueryModal' import { RenameQueryModal } from '@/components/interfaces/SQLEditor/RenameQueryModal' @@ -142,6 +146,17 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const snippet = snapV2.snippets[id as string]?.snippet + const openDraftSqlTabIds = useMemo( + () => getOpenDraftSqlTabIds(tabs.openTabs, tabs.tabsMap), + [tabs.openTabs, tabs.tabsMap] + ) + + const isHiddenDraftSqlTab = useCallback( + (snippetId: string, snippetToCheck?: SnippetWithContent) => + shouldHideDraftSqlTabFromNav(snippetId, openDraftSqlTabIds, snippetToCheck), + [openDraftSqlTabIds] + ) + // ========================== // Private snippets & folders // ========================== @@ -195,7 +210,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { if ( snippet && snippet.visibility === 'user' && - !snippet.isNotSavedInDatabaseYet && + !isHiddenDraftSqlTab(snippet.id, snippet) && !snippetInfo.snippetIds.has(snippet.id) ) { snippetInfo.snippetIds.add(snippet.id) @@ -203,11 +218,21 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { } return snippetInfo - }, [privateSnippetsPages?.pages, subResults, isLoading, isPlaceholderData, isFetching, snippet]) + }, [ + privateSnippetsPages?.pages, + subResults, + isLoading, + isPlaceholderData, + isFetching, + snippet, + isHiddenDraftSqlTab, + ]) const privateSnippets = useMemo(() => { const userSnippets = - filteredSnippets.snippets?.filter((snippet) => snippet.visibility === 'user') ?? [] + filteredSnippets.snippets?.filter( + (snippet) => snippet.visibility === 'user' && !isHiddenDraftSqlTab(snippet.id) + ) ?? [] return mergeSnippetsWithLogSql( userSnippets, @@ -216,7 +241,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { if (sort === 'name') return a.name.localeCompare(b.name) else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() }) - }, [filteredSnippets.snippets, logSqlSnippets, sort]) + }, [filteredSnippets.snippets, logSqlSnippets, sort, isHiddenDraftSqlTab]) const folders = useSnippetFolders(projectRef!) const { data: snippetCountData, error: snippetCountError } = useContentCountQuery({ @@ -264,7 +289,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { if ( snippet && snippet.favorite && - !snippet.isNotSavedInDatabaseYet && + !isHiddenDraftSqlTab(snippet.id, snippet) && !snippets.find((x) => x.id === snippet.id) ) { snippets.push(snippet as SqlSnippet) @@ -277,13 +302,14 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { return ( withLogSqlFavorites + .filter((snippet) => !isHiddenDraftSqlTab(snippet.id)) .map((snippet) => ({ ...snippet, folder_id: undefined })) .sort((a, b) => { if (sort === 'name') return a.name.localeCompare(b.name) else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() }) ?? [] ) - }, [favoriteSqlSnippetsData?.pages, snippet, sort, logSqlSnippets]) + }, [favoriteSqlSnippetsData?.pages, snippet, sort, logSqlSnippets, isHiddenDraftSqlTab]) const numFavoriteSnippets = snippetCountData?.favorites ?? 0 @@ -315,19 +341,21 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { if ( snippet && snippet.visibility === 'project' && - !snippet.isNotSavedInDatabaseYet && + !isHiddenDraftSqlTab(snippet.id, snippet) && !snippets.find((x) => x.id === snippet.id) ) { snippets.push(snippet as SqlSnippet) } return ( - snippets.sort((a, b) => { - if (sort === 'name') return a.name.localeCompare(b.name) - else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() - }) ?? [] + snippets + .filter((snippet) => !isHiddenDraftSqlTab(snippet.id)) + .sort((a, b) => { + if (sort === 'name') return a.name.localeCompare(b.name) + else return new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf() + }) ?? [] ) - }, [sharedSqlSnippetsData?.pages, snippet, sort, logSqlSnippets]) + }, [sharedSqlSnippetsData?.pages, snippet, sort, logSqlSnippets, isHiddenDraftSqlTab]) const numProjectSnippets = snippetCountData?.shared ?? 0 const numTotalSnippets = diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/useSqlEditorCreateActions.ts b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/useSqlEditorCreateActions.ts index f99185adce5..ffce1ed5d98 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/useSqlEditorCreateActions.ts +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/useSqlEditorCreateActions.ts @@ -5,6 +5,7 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { useCallback } from 'react' import { toast } from 'sonner' +import { createDraftSqlTab } from '@/components/interfaces/SQLEditor/createDraftSqlTab' import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' @@ -14,6 +15,7 @@ import { IS_PLATFORM } from '@/lib/constants' import { useProfile } from '@/lib/profile' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' +import { useTabsStateSnapshot } from '@/state/tabs' export type SqlEditorSnippetCreateTarget = 'private' | 'shared' | 'favorite' @@ -23,6 +25,7 @@ export function useSqlEditorCreateActions() { const { profile } = useProfile() const { data: project } = useSelectedProjectQuery() const snapV2 = useSqlEditorV2StateSnapshot() + const tabs = useTabsStateSnapshot() const assistant = useAiAssistantStateSnapshot() const [, setShowNewReportModal] = useQueryState('newReport', parseAsBoolean.withDefault(false)) const { reportsAll } = useIsFeatureEnabled(['reports:all']) @@ -53,7 +56,14 @@ export function useSqlEditorCreateActions() { try { if (target === 'private') { - await router.push(`/project/${projectRef}/sql/new?skip=true`) + createDraftSqlTab({ + projectRef, + projectId: project.id, + ownerId: profile.id, + snapV2, + tabs, + router, + }) return } @@ -74,7 +84,7 @@ export function useSqlEditorCreateActions() { toast.error(`Failed to create new query: ${error.message}`) } }, - [canCreateSQLSnippet, profile, project, projectRef, router, snapV2] + [canCreateSQLSnippet, profile, project, projectRef, router, snapV2, tabs] ) const createNewChat = useCallback(() => { diff --git a/apps/studio/components/layouts/Tabs/SortableTab.tsx b/apps/studio/components/layouts/Tabs/SortableTab.tsx index 679a0d465cf..90853dd36f8 100644 --- a/apps/studio/components/layouts/Tabs/SortableTab.tsx +++ b/apps/studio/components/layouts/Tabs/SortableTab.tsx @@ -131,7 +131,7 @@ export const SortableTab = ({ className={cn( 'flex items-center', isToggleGroupTab - ? 'min-w-0 max-w-[240px] shrink' + ? 'min-w-0 flex-1 max-w-fit overflow-hidden' : 'h-(--header-height) shrink-0 first-of-type:border-l' )} > @@ -140,7 +140,7 @@ export const SortableTab = ({ value={tab.id} aria-label={tab.label || 'Untitled'} className={cn( - 'group relative flex w-auto min-w-0 max-w-full items-center gap-1.5 overflow-hidden px-2 text-xs', + 'group relative flex w-full min-w-0 flex-1 max-w-fit items-center gap-1.5 overflow-hidden px-2 text-xs', SQL_EDITOR_SIDEBAR_SEARCH_ROW_HEIGHT_CLASSNAME, tab.isPreview && 'italic font-light' )} diff --git a/apps/studio/components/layouts/Tabs/TabPreview.tsx b/apps/studio/components/layouts/Tabs/TabPreview.tsx index 85fe2831bfb..0e56c3b4503 100644 --- a/apps/studio/components/layouts/Tabs/TabPreview.tsx +++ b/apps/studio/components/layouts/Tabs/TabPreview.tsx @@ -27,7 +27,7 @@ export const TabPreview = ({ 'relative flex items-center text-xs shadow-lg', variant === 'toggle-group' ? cn( - 'max-w-[240px] gap-1.5 overflow-hidden rounded-md bg-surface-300 px-2', + 'max-w-fit gap-1.5 overflow-hidden rounded-md bg-surface-300 px-2', SQL_EDITOR_SIDEBAR_SEARCH_ROW_HEIGHT_CLASSNAME ) : 'h-10 gap-2 rounded-xs bg-dash-sidebar px-3 dark:bg-surface-100' diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index cf2e5254d07..6bfde4b2cc6 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -9,7 +9,7 @@ import { import { horizontalListSortingStrategy, SortableContext } from '@dnd-kit/sortable' import { useParams } from 'common' import { AnimatePresence, motion } from 'framer-motion' -import { Plus, X } from 'lucide-react' +import { Plus } from 'lucide-react' import { useRouter } from 'next/router' import { cn, @@ -30,7 +30,10 @@ import { CollapseButton } from './CollapseButton' import { SortableTab } from './SortableTab' import { TabPreview } from './TabPreview' import { useTabsScroll } from './Tabs.utils' +import { clearPersistedDraftSqlTab } from '@/components/interfaces/SQLEditor/createDraftSqlTab' +import { useCreateDraftSqlTab } from '@/components/interfaces/SQLEditor/useCreateDraftSqlTab' import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' +import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' import { editorEntityTypes, isSqlEditorTab, useTabsStateSnapshot, type Tab } from '@/state/tabs' export const EditorTabs = () => { @@ -40,6 +43,8 @@ export const EditorTabs = () => { const editor = useEditorType() const tabs = useTabsStateSnapshot() + const snapV2 = useSqlEditorV2StateSnapshot() + const { createDraftTab } = useCreateDraftSqlTab() const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -52,8 +57,6 @@ export const EditorTabs = () => { .map((id) => tabs.tabsMap[id]) .filter((tab) => tab !== undefined) as Tab[] - const hasNewTab = router.asPath.includes('/new') - // Filter by editor type - only show SQL tabs for SQL editor and table tabs for table editor const editorTabs = !!editor ? openTabs.filter((tab) => editorEntityTypes[editor]?.includes(tab.type)) @@ -79,8 +82,25 @@ export const EditorTabs = () => { } } + const cleanupDraftTabState = (tabId: string) => { + const tab = tabs.tabsMap[tabId] + const draftSqlId = tab?.metadata?.isDraft ? tab.metadata?.sqlId : undefined + if (draftSqlId && ref) { + snapV2.removeSnippet(draftSqlId, true) + clearPersistedDraftSqlTab(ref, draftSqlId) + } + } + const handleClose = (tabId: string) => { - tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory }) + tabs.handleTabClose({ + id: tabId, + router, + editor, + onClearDashboardHistory, + onClose: () => { + cleanupDraftTabState(tabId) + }, + }) } const handleCloseAll = () => { @@ -90,6 +110,7 @@ export const EditorTabs = () => { ? tabs.openTabs.filter((x) => !isSqlEditorTab(x, tabs.tabsMap)) : tabs.openTabs.filter((x) => isSqlEditorTab(x, tabs.tabsMap)) + tabsToClose.forEach(cleanupDraftTabState) tabs.removeTabs(tabsToClose) onClearDashboardHistory() router.push(`/project/${ref}/${editor === 'table' ? 'editor' : 'sql'}`) @@ -103,6 +124,7 @@ export const EditorTabs = () => { ? tabs.openTabs.filter((x) => !isSqlEditorTab(x, tabs.tabsMap) && x !== tabId) : tabs.openTabs.filter((x) => isSqlEditorTab(x, tabs.tabsMap) && x !== tabId) + tabsToClose.forEach(cleanupDraftTabState) tabs.removeTabs(tabsToClose) onClearDashboardHistory() @@ -121,6 +143,7 @@ export const EditorTabs = () => { const tabIdx = openedTabs.indexOf(tabId) const activeTabIdx = openedTabs.indexOf(tabs.activeTab!) const tabsToClose = openedTabs.slice(tabIdx + 1) + tabsToClose.forEach(cleanupDraftTabState) tabs.removeTabs(tabsToClose) const isActiveTabClosed = tabIdx < activeTabIdx @@ -139,7 +162,7 @@ export const EditorTabs = () => { tabCount: editorTabs.length, enabled: editor !== 'sql', }) - const activeTabValue = hasNewTab ? 'new' : (tabs.activeTab ?? undefined) + const activeTabValue = tabs.activeTab ?? undefined const isSqlEditor = editor === 'sql' const sortableTabs = ( @@ -174,54 +197,32 @@ export const EditorTabs = () => { ) - const newTabCloseButton = ( - { - 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') - }} - > - - - ) - const addTabButton = ( - {!hasNewTab && ( - - router.push( - `/project/${router.query.ref}/${editor === 'table' ? 'editor' : 'sql'}/new?skip=true` - ) + { + if (editor === 'sql') { + createDraftTab() + } else { + void router.push(`/project/${router.query.ref}/editor/new?skip=true`) } - initial={{ opacity: 0, scale: 0.8, x: -10 }} - animate={{ opacity: 1, scale: 1, x: 0 }} - transition={{ duration: 0.2 }} - > - - - )} + }} + initial={{ opacity: 0, scale: 0.8, x: -10 }} + animate={{ opacity: 1, scale: 1, x: 0 }} + transition={{ duration: 0.2 }} + > + + ) @@ -241,23 +242,9 @@ export const EditorTabs = () => { onValueChange={(value) => { if (value) handleTabChange(value) }} - className="flex w-max min-w-0 max-w-full flex-nowrap justify-start overflow-hidden" + className="flex min-w-0 flex-1 flex-nowrap justify-start overflow-hidden" > {sortableTabs} - {hasNewTab && ( - - - New - {newTabCloseButton} - - )} {addTabButton} @@ -277,25 +264,6 @@ export const EditorTabs = () => { )} > {sortableTabs} - {hasNewTab && ( - - -
- New -
- {newTabCloseButton} -
- - )} {addTabButton}
diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 655489eb708..df1754795da 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -2,10 +2,15 @@ import { usePrevious } from '@uidotdev/usehooks' import { useParams } from 'common/hooks/useParams' import Link from 'next/link' import { useRouter } from 'next/router' -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { Button } from 'ui' import { Admonition } from 'ui-patterns' +import { + createDraftSqlTab, + isDraftSqlSnippet, + restoreDraftSqlTab, +} from '@/components/interfaces/SQLEditor/createDraftSqlTab' import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor' import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants' import { getSnippetQuerySource } from '@/components/interfaces/SQLEditor/sqlSnippet.utils' @@ -18,6 +23,7 @@ import { useContentIdQuery } from '@/data/content/content-id-query' import { useDashboardHistory } from '@/hooks/misc/useDashboardHistory' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' +import { useProfile } from '@/lib/profile' import { useQueryExecutionSourceSnapshot } from '@/state/query-execution-source' import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from '@/state/sql-editor-v2' import { createTabId, useTabsStateSnapshot } from '@/state/tabs' @@ -25,26 +31,32 @@ import type { NextPageWithLayout } from '@/types' const SqlEditor: NextPageWithLayout = () => { const router = useRouter() - const { id, ref, content, skip } = useParams() + const { id, ref, content, source } = useParams() const previousRoute = usePrevious(id) const { data: project } = useSelectedProjectQuery() + const { profile } = useProfile() + const creatingDraftRef = useRef(false) const editor = useEditorType() const tabs = useTabsStateSnapshot() const snapV2 = useSqlEditorV2StateSnapshot() const querySourceState = useQueryExecutionSourceSnapshot() - const { history, setLastVisitedSnippet } = useDashboardHistory() + const { setLastVisitedSnippet } = useDashboardHistory() const allSnippets = useSnippets(ref!) const snippet = allSnippets.find((x) => x.id === id) + const tabId = id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined + const tab = tabId ? tabs.tabsMap[tabId] : undefined + const isDraftTab = isDraftSqlSnippet(snippet) || tab?.metadata?.isDraft === true - const tabId = !!id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined - - // [Joshen] May need to investigate separately, but occasionally addSnippet doesnt exist in - // the snapV2 valtio store for some reason hence why the added typeof check here const canFetchContentBasedOnId = Boolean( - id !== 'new' && typeof snapV2.addSnippet === 'function' && !snippet?.isNotSavedInDatabaseYet + id && + id !== 'new' && + typeof snapV2.addSnippet === 'function' && + !isDraftTab && + !snippet?.isNotSavedInDatabaseYet ) + const { data, error, isError } = useContentIdQuery( { projectRef: ref, id }, { @@ -57,42 +69,72 @@ const SqlEditor: NextPageWithLayout = () => { isError && error.code === 404 && error.message.includes('Content not found') const invalidId = isError && error.code === 400 && error.message.includes('Invalid uuid') - // [Joshen] Atm we suspect that replication lag is causing this to happen whereby a newly created snippet - // shows the "Unable to find snippet" error which blocks the whole UI - // Am opting to silently swallow this error, since the saves are still going through and we're scoping this behaviour - // behaviour down to a very specific use case too with all these conditionals - // More details: https://github.com/supabase/supabase/pull/39389 const snippetMissingImmediatelyAfterCreating = !!snippet && snippetMissing && previousRoute === 'new' && 'isNotSavedInDatabaseYet' in snippet + useEffect(() => { + if ( + id !== 'new' || + !router.isReady || + !ref || + !project || + !profile || + creatingDraftRef.current + ) { + return + } + + creatingDraftRef.current = true + + const initialSql = typeof content === 'string' ? content : '' + const querySource = source === 'logs' ? ('logs' as const) : ('database' as const) + + const draftId = createDraftSqlTab({ + projectRef: ref, + projectId: project.id, + ownerId: profile.id, + snapV2, + tabs, + initialSql, + querySource, + skipNavigation: true, + }) + + if (source === 'logs') { + querySourceState.setExecutionSource('logs') + } + + void router.replace(`/project/${ref}/sql/${draftId}`) + }, [content, id, profile, project, querySourceState, ref, router, snapV2, source, tabs]) + + useEffect(() => { + if (!router.isReady || !id || id === 'new' || !ref || !project || !profile) return + if (!tab?.metadata?.isDraft || snippet) return + + restoreDraftSqlTab({ + draftId: id, + projectRef: ref, + projectId: project.id, + ownerId: profile.id, + snapV2, + name: tab.metadata?.name ?? generateSnippetTitle(), + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady, id, ref, project, profile, tab?.metadata?.isDraft, snippet]) + useEffect(() => { if (ref && data && project) { - // [Joshen] Check if snippet belongs to the current project if (!IS_PLATFORM || data.project_id === project.id) { snapV2.setSnippet(ref, data as unknown as SnippetWithContent) setLastVisitedSnippet(data.id) } else { setLastVisitedSnippet(undefined) - router.replace(`/project/${ref}/sql/new`) + void router.replace(`/project/${ref}/sql/new?skip=true`) } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ref, data, project]) - useEffect(() => { - if (!router.isReady || !ref || id === 'new') return - - if (snippetMissing && !snippetMissingImmediatelyAfterCreating) { - setLastVisitedSnippet(undefined) - const tabId = createTabId('sql', { id }) - if (tabs.openTabs.includes(tabId)) { - tabs.removeTab(tabId) - } - router.replace(`/project/${ref}/sql/new?skip=true`) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [router.isReady, ref, id, snippetMissing, snippetMissingImmediatelyAfterCreating]) - useEffect(() => { if (router.query.source === 'logs') { querySourceState.setExecutionSource('logs') @@ -102,40 +144,44 @@ const SqlEditor: NextPageWithLayout = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.query.source, data]) - // Load the last visited snippet when landing on /new - useEffect(() => { - if ( - id === 'new' && - skip !== 'true' && // [Joshen] Skip flag implies to skip loading the last visited snippet - history.sql !== undefined && - content === undefined - ) { - const snippet = allSnippets.find((snippet) => snippet.id === history.sql) - if (snippet !== undefined) router.replace(`/project/${ref}/sql/${history.sql}`) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, allSnippets, content]) - - // Watch for route changes useEffect(() => { if (!router.isReady || !id || id === 'new') return - const tabId = createTabId('sql', { id }) - const snippet = allSnippets.find((x) => x.id === id) + const nextTabId = createTabId('sql', { id }) + const routeSnippet = allSnippets.find((x) => x.id === id) tabs.addTab({ - id: tabId, + id: nextTabId, type: 'sql', - label: snippet?.name || generateSnippetTitle(), + label: routeSnippet?.name || tab?.metadata?.name || generateSnippetTitle(), metadata: { sqlId: id, - name: snippet?.name, + name: routeSnippet?.name ?? tab?.metadata?.name, + isDraft: isDraftTab, }, }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [router.isReady, id]) - if (snippetMissing && !snippetMissingImmediatelyAfterCreating) { + useEffect(() => { + if (!router.isReady || !ref || !id || id === 'new') return + + if (snippetMissing && !snippetMissingImmediatelyAfterCreating && !isDraftTab) { + setLastVisitedSnippet(undefined) + const staleTabId = createTabId('sql', { id }) + if (tabs.openTabs.includes(staleTabId)) { + tabs.removeTab(staleTabId) + } + void router.replace(`/project/${ref}/sql/new?skip=true`) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.isReady, ref, id, snippetMissing, snippetMissingImmediatelyAfterCreating, isDraftTab]) + + if (id === 'new') { + return null + } + + if (snippetMissing && !snippetMissingImmediatelyAfterCreating && !isDraftTab) { return null } diff --git a/apps/studio/pages/project/[ref]/sql/index.tsx b/apps/studio/pages/project/[ref]/sql/index.tsx index 570ef165060..fca900a33af 100644 --- a/apps/studio/pages/project/[ref]/sql/index.tsx +++ b/apps/studio/pages/project/[ref]/sql/index.tsx @@ -37,7 +37,7 @@ const SQLEditorIndexPage: NextPageWithLayout = () => { router.replace(`/project/${projectRef}/sql/${lastTab.metadata.sqlId}`) } } else { - router.replace(`/project/${projectRef}/sql/new`) + router.replace(`/project/${projectRef}/sql/new?skip=true`) } } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/studio/state/sql-editor-v2.ts b/apps/studio/state/sql-editor-v2.ts index ed78e93a11b..b42b7d9752f 100644 --- a/apps/studio/state/sql-editor-v2.ts +++ b/apps/studio/state/sql-editor-v2.ts @@ -6,10 +6,12 @@ import { proxy, ref, snapshot, subscribe, useSnapshot } from 'valtio' import { devtools, proxyMap } from 'valtio/utils' import type { QueryPlanRow } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.types' +import { persistDraftSqlTab } from '@/components/interfaces/SQLEditor/draftSqlTabStorage.utils' import { DiffType } from '@/components/interfaces/SQLEditor/SQLEditor.types' import { buildSnippetUpsertContent, getSnippetContentType, + getSnippetQuerySource, getSnippetSqlFromContent, } from '@/components/interfaces/SQLEditor/sqlSnippet.utils' import { upsertContent, UpsertContentPayload } from '@/data/content/content-upsert-mutation' @@ -33,10 +35,16 @@ type StateSnippet = { snippet: SnippetWithContent } +function isDraftSqlTabId(id: string) { + return sqlEditorState.snippets[id]?.snippet?.isDraftTab === true +} + // [Joshen] API codegen is somehow missing the content property export interface SnippetWithContent extends Snippet { content?: SqlSnippets.Content isNotSavedInDatabaseYet?: boolean + /** Ephemeral SQL editor tab — not shown in the sidebar until saved */ + isDraftTab?: boolean } const NEW_FOLDER_ID = 'new-folder' @@ -168,7 +176,7 @@ export const sqlEditorState = proxy({ sql: sqlValue, label: snippet.name, }) - } else { + } else if (!isDraftSqlTabId(id)) { sqlEditorState.needsSaving.set(id, true) } } @@ -220,6 +228,18 @@ export const sqlEditorState = proxy({ return } + if (isDraftSqlTabId(id)) { + const stateSnippet = sqlEditorState.snippets[id] + if (stateSnippet) { + persistDraftSqlTab(stateSnippet.projectRef, id, { + sql, + name: snippet.name, + querySource: getSnippetQuerySource(snippet), + }) + } + return + } + if (!skipSave) { sqlEditorState.needsSaving.set(id, shouldInvalidate) } @@ -345,6 +365,7 @@ export const sqlEditorState = proxy({ } return } + if (isDraftSqlTabId(id)) return sqlEditorState.needsSaving.set(id, true) }, @@ -549,6 +570,11 @@ if (typeof window !== 'undefined') { return } + if (isDraftSqlTabId(id)) { + sqlEditorState.needsSaving.delete(id) + return + } + const { name, description, diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx index 211c16dcf38..f9d0835bc1c 100644 --- a/apps/studio/state/tabs.tsx +++ b/apps/studio/state/tabs.tsx @@ -21,9 +21,7 @@ export function isSqlEditorTab(tabOrId: Tab | string, tabsMap?: Record { + updateTab: ( + id: string, + updates: { + label?: string + scrollTop?: number + metadata?: Partial> + } + ) => { if (!!store.tabsMap[id]) { if ('label' in updates) { store.tabsMap[id].label = updates.label @@ -260,6 +266,12 @@ export function createTabsState(projectRef: string) { if ('scrollTop' in updates && store.tabsMap[id].metadata) { store.tabsMap[id].metadata.scrollTop = updates.scrollTop } + if (updates.metadata && store.tabsMap[id].metadata) { + store.tabsMap[id].metadata = { + ...store.tabsMap[id].metadata, + ...updates.metadata, + } + } } }, // Function to remove a tab from the store diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index e2c5f48fb10..6f8163bffe8 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -48,6 +48,7 @@ export const LOCAL_STORAGE_KEYS = { SQL_EDITOR_SQL_BLOCK_ACKNOWLEDGED: (ref: string) => `sql-editor-sql-block-acknowledged-${ref}`, SQL_EDITOR_SECTION_STATE: (ref: string) => `sql-editor-section-state-${ref}`, SQL_EDITOR_SORT: (ref: string) => `sql-editor-sort-${ref}`, + SQL_EDITOR_DRAFT_TABS: (ref: string) => `sql-editor-draft-tabs-${ref}`, LOG_EXPLORER_SPLIT_SIZE: 'supabase_log-explorer-split-size', GRAPHQL_INTROSPECTION_NOTICE_COLLAPSED: (ref: string) =>