diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index b77e797f3d0..fdeabb3c52e 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -5,7 +5,6 @@ import { useEffect } from 'react' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' import { removeTabsByEditor } from 'state/tabs' @@ -48,11 +47,11 @@ const FeaturePreviewModal = () => { groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' }, }) - if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) { - removeTabsByEditor(ref as string | undefined, 'table') + if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_TABLE_EDITOR_TABS) { + removeTabsByEditor(ref, 'table') } - if (selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) { - removeTabsByEditor(ref as string | undefined, 'sql') + if (ref && selectedFeatureKey === LOCAL_STORAGE_KEYS.UI_SQL_EDITOR_TABS) { + removeTabsByEditor(ref, 'sql') } } diff --git a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx index 4306d01e034..a98c4ac0a2b 100644 --- a/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/MonacoEditor.tsx @@ -9,7 +9,7 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' -import { makeActiveTabPermanent } from 'state/tabs' +import { useTabsStateSnapshot } from 'state/tabs' import { cn } from 'ui' import { Admonition } from 'ui-patterns' import { untitledSnippetTitle } from './SQLEditor.constants' @@ -50,6 +50,7 @@ const MonacoEditor = ({ const { ref, content } = useParams() const project = useSelectedProject() const snapV2 = useSqlEditorV2StateSnapshot() + const tabsSnap = useTabsStateSnapshot() const aiSnap = useAiAssistantStateSnapshot() @@ -152,7 +153,7 @@ const MonacoEditor = ({ function handleEditorChange(value: string | undefined) { // make active tab permanent - makeActiveTabPermanent(ref) + tabsSnap.makeActiveTabPermanent() const snippetCheck = snapV2.snippets[id] diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index b40b57692af..b11aa92fa28 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -13,7 +13,7 @@ import type { SqlSnippet } from 'data/content/sql-snippets-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' -import { createTabId, updateTab } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { AiIconAnimation, Button, Form, Input, Modal } from 'ui' import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' @@ -35,6 +35,7 @@ const RenameQueryModal = ({ const organization = useSelectedOrganization() const snapV2 = useSqlEditorV2StateSnapshot() + const tabsSnap = useTabsStateSnapshot() const { data: subscription } = useOrgSubscriptionQuery( { orgSlug: organization?.slug }, { enabled: visible } @@ -111,7 +112,7 @@ const RenameQueryModal = ({ if (isSQLEditorTabsEnabled && ref) { const tabId = createTabId('sql', { id }) - updateTab(ref, tabId, { label: nameInput }) + tabsSnap.updateTab(tabId, { label: nameInput }) } toast.success('Successfully renamed snippet!') diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 6054a051341..df52ac1f564 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -36,7 +36,7 @@ import { useGetImpersonatedRoleState, } from 'state/role-impersonation-state' import { getSqlEditorV2StateSnapshot, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' -import { createTabId, getTabsStore, updateTab } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { Button, DropdownMenu, @@ -52,7 +52,6 @@ import { TooltipTrigger, cn, } from 'ui' -import { useSnapshot } from 'valtio' import { useIsSQLEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' import { useSqlEditorDiff, useSqlEditorPrompt } from './hooks' @@ -90,8 +89,7 @@ export const SQLEditor = () => { const org = useSelectedOrganization() const queryClient = useQueryClient() - const store = getTabsStore(ref) - const tabs = useSnapshot(store) + const tabs = useTabsStateSnapshot() const aiSnap = useAiAssistantStateSnapshot() const snapV2 = useSqlEditorV2StateSnapshot() const getImpersonatedRoleState = useGetImpersonatedRoleState() @@ -222,7 +220,7 @@ export const SQLEditor = () => { snapV2.renameSnippet({ id, name }) if (isSQLEditorTabsEnabled && ref) { const tabId = createTabId('sql', { id }) - updateTab(ref, tabId, { label: name }) + tabs.updateTab(tabId, { label: name }) } } catch (error) { // [Joshen] No error handler required as this happens in the background and not necessary to ping the user @@ -529,7 +527,7 @@ export const SQLEditor = () => { return () => { if (ref) { const tabId = createTabId('sql', { id }) - updateTab(ref, tabId, { scrollTop: scrollTopRef.current }) + tabs.updateTab(tabId, { scrollTop: scrollTopRef.current }) } } // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 34b6910570c..85cc650cde9 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -25,7 +25,7 @@ import { getTables } from 'data/tables/tables-query' import { useUrlState } from 'hooks/ui/useUrlState' import { useGetImpersonatedRoleState } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { createTabId, updateTab } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import type { Dictionary } from 'types' import { SonnerProgress } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' @@ -69,6 +69,7 @@ const SidePanelEditor = ({ }: SidePanelEditorProps) => { const { ref } = useParams() const snap = useTableEditorStateSnapshot() + const tabsSnap = useTabsStateSnapshot() const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) @@ -494,7 +495,7 @@ const SidePanelEditor = ({ if (isTableEditorTabsEnabled && ref && payload.name) { // [Joshen] Only table entities can be updated via the dashboard const tabId = createTabId(ENTITY_TYPE.TABLE, { id: selectedTable.id }) - updateTab(ref, tabId, { label: payload.name }) + tabsSnap.updateTab(tabId, { label: payload.name }) } toast.success(`Successfully updated ${table.name}!`, { id: toastId }) } diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 2ad8c0c54ca..011a66553fa 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -21,7 +21,7 @@ import { useUrlState } from 'hooks/ui/useUrlState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' import { useAppStateSnapshot } from 'state/app-state' import { TableEditorTableStateContextProvider } from 'state/table-editor-table' -import { createTabId, handleTabClose, makeActiveTabPermanent, useTabsStore } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { Button } from 'ui' import { Admonition, GenericSkeletonLoader } from 'ui-patterns' import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext' @@ -44,7 +44,7 @@ export const TableGridEditor = ({ const appSnap = useAppStateSnapshot() const { ref: projectRef, id } = useParams() - const tabs = useTabsStore(projectRef) + const tabs = useTabsStateSnapshot() const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const { selectedSchema } = useQuerySchemaState() @@ -83,7 +83,7 @@ export const TableGridEditor = ({ if (isTableEditorTabsEnabled && selectedTable) { // Close tab const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id }) - handleTabClose({ ref: projectRef, id: tabId, router, editor, onClearDashboardHistory }) + tabs.handleTabClose({ id: tabId, router, editor, onClearDashboardHistory }) } else { const tables = await getTables(selectedSchema) if (tables.length > 0) { @@ -122,8 +122,7 @@ export const TableGridEditor = ({ className="mt-2" onClick={() => { if (tabId) { - handleTabClose({ - ref: projectRef, + tabs.handleTabClose({ id: tabId, router, editor, @@ -156,7 +155,7 @@ export const TableGridEditor = ({ return ( // When any click happens in a table tab, the tab becomes permanent -
makeActiveTabPermanent(project?.ref)}> +
tabs.makeActiveTabPermanent()}> - - - - - {children} - - - - + + + + + + {children} + + + + + ) diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 5f3f340550d..4db2758c4ea 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -7,7 +7,7 @@ import { untitledSnippetTitle } from 'components/interfaces/SQLEditor/SQLEditor. import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' import { EmptyPrivateQueriesPanel } from 'components/layouts/SQLEditorLayout/PrivateSqlSnippetEmpty' import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton' -import { sqlEditorTabsCleanup } from 'components/layouts/Tabs/Tabs.utils' +import { useSqlEditorTabsCleanup } from 'components/layouts/Tabs/Tabs.utils' import { useContentCountQuery } from 'data/content/content-count-query' import { useContentDeleteMutation } from 'data/content/content-delete-mutation' import { getContentById } from 'data/content/content-id-query' @@ -28,7 +28,7 @@ import { useSnippetFolders, useSqlEditorV2StateSnapshot, } from 'state/sql-editor-v2' -import { createTabId, getTabsStore, makeTabPermanent, removeTabs, useTabsStore } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { SqlSnippets } from 'types' import { Separator, TreeView } from 'ui' import { @@ -58,8 +58,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const project = useSelectedProject() const snapV2 = useSqlEditorV2StateSnapshot() - const tabStore = getTabsStore(projectRef) - const tabs = useTabsStore(projectRef) + const tabs = useTabsStateSnapshot() const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled() const [sectionVisibility, setSectionVisibility] = useLocalStorage( @@ -274,10 +273,13 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { [projectSnippetsTreeState] ) - const allSnippetsInView = [ - ...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []), - ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []), - ] + const allSnippetsInView = useMemo( + () => [ + ...(privateSnippetsPages?.pages.flatMap((x) => x.contents) ?? []), + ...(sharedSqlSnippetsData?.pages.flatMap((x) => x.contents) ?? []), + ], + [privateSnippetsPages, sharedSqlSnippetsData] + ) // ========================== // Snippet mutations from RQ @@ -295,7 +297,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { // Update Tabs state - currently unknown how to differentiate between sql and non-sql content // so we're just deleting all tabs for with matching IDs const tabIds = data.map((id) => createTabId('sql', { id })) - removeTabs(projectRef, tabIds) + tabs.removeTabs(tabIds) } }, onError: (error, data) => { @@ -537,11 +539,12 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { }) }, [projectRef, sharedSqlSnippetsData?.pages]) + const sqlEditorTabsCleanup = useSqlEditorTabsCleanup() useEffect(() => { - if (projectRef && isSuccess && isSQLEditorTabsEnabled) { - sqlEditorTabsCleanup({ ref: projectRef, snippets: allSnippetsInView as any }) + if (isSuccess && isSQLEditorTabsEnabled) { + sqlEditorTabsCleanup({ snippets: allSnippetsInView as any }) } - }, [isSuccess, isSharedSqlSnippetsSuccess, allSnippetsInView, isSQLEditorTabsEnabled]) + }, [allSnippetsInView, isSQLEditorTabsEnabled, isSuccess, sqlEditorTabsCleanup]) return ( <> @@ -577,7 +580,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId + const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) @@ -589,7 +592,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { isPreview={isPreview} onDoubleClick={(e) => { e.preventDefault() - makeTabPermanent(projectRef, tabId) + tabs.makeTabPermanent(tabId) }} element={element} onSelectDelete={() => { @@ -661,7 +664,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId + const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) @@ -673,7 +676,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { isPreview={isPreview} onDoubleClick={(e) => { e.preventDefault() - makeTabPermanent(projectRef, tabId) + tabs.makeTabPermanent(tabId) }} element={element} onSelectDelete={() => { @@ -751,7 +754,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { const tabId = createTabId('sql', { id: element?.metadata?.id as unknown as Snippet['id'], }) - const isPreview = isSQLEditorTabsEnabled && tabStore.previewTabId === tabId + const isPreview = isSQLEditorTabsEnabled && tabs.previewTabId === tabId const isActive = !isPreview && element.metadata?.id === id const isSelected = selectedSnippets.some((x) => x.id === element.metadata?.id) @@ -829,7 +832,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => { }} onDoubleClick={(e) => { e.preventDefault() - makeTabPermanent(projectRef, tabId) + tabs.makeTabPermanent(tabId) }} /> ) diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index f6117f9115a..d4d56add08e 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -3,7 +3,6 @@ import { Clipboard, Copy, Download, Edit, Lock, MoreHorizontal, Trash, Unlock } import Link from 'next/link' import Papa from 'papaparse' import { toast } from 'sonner' -import { useSnapshot } from 'valtio' import { IS_PLATFORM } from 'common' import { @@ -28,7 +27,7 @@ import { fetchAllTableRows } from 'data/table-rows/table-rows-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { copyToClipboard } from 'lib/helpers' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { createTabId, getTabsStore, makeTabPermanent } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { cn, DropdownMenu, @@ -66,10 +65,9 @@ const EntityListItem: ItemRenderer = ({ // For tabs preview flag logic const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() const tabId = createTabId(entity.type, { id: entity.id }) - const tabStore = getTabsStore(projectRef) - const isPreview = isTableEditorTabsEnabled ? tabStore.previewTabId === tabId : false + const tabs = useTabsStateSnapshot() + const isPreview = isTableEditorTabsEnabled ? tabs.previewTabId === tabId : false - const tabs = useSnapshot(tabStore) const isOpened = Object.values(tabs.tabsMap).some((tab) => tab.metadata?.tableId === entity.id) const isActive = Number(id) === entity.id const canEdit = isActive && !isLocked @@ -233,7 +231,7 @@ const EntityListItem: ItemRenderer = ({ onDoubleClick={(e) => { e.preventDefault() const tabId = createTabId(entity.type, { id: entity.id }) - makeTabPermanent(projectRef, tabId) + tabs.makeTabPermanent(tabId) }} > <> diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 3c4ea8c51bb..a306730b114 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -40,7 +40,7 @@ import { InnerSideBarFilters, } from 'ui-patterns/InnerSideMenu' import { useProjectContext } from '../ProjectLayout/ProjectContext' -import { tableEditorTabsCleanUp } from '../Tabs/Tabs.utils' +import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils' import EntityListItem from './EntityListItem' import { TableMenuEmptyState } from './TableMenuEmptyState' @@ -115,12 +115,13 @@ const TableEditorMenu = () => { } }, [selectedTable?.schema]) + const tableEditorTabsCleanUp = useTableEditorTabsCleanUp() useEffect(() => { // Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session - if (isTableEditorTabsEnabled && ref && entityTypes && !searchText) { - tableEditorTabsCleanUp({ ref, schemas: [selectedSchema], entities: entityTypes }) + if (isTableEditorTabsEnabled && entityTypes && !searchText) { + tableEditorTabsCleanUp({ schemas: [selectedSchema], entities: entityTypes }) } - }, [entityTypes]) + }, [entityTypes, isTableEditorTabsEnabled, searchText, selectedSchema, tableEditorTabsCleanUp]) return ( <> diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx index 3617b770a0d..ba199aab480 100644 --- a/apps/studio/components/layouts/Tabs/NewTab.tsx +++ b/apps/studio/components/layouts/Tabs/NewTab.tsx @@ -16,7 +16,7 @@ import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { useTableEditorStateSnapshot } from 'state/table-editor' -import { getTabsStore } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { Button, cn, @@ -41,12 +41,12 @@ export function NewTab() { const snap = useTableEditorStateSnapshot() const snapV2 = useSqlEditorV2StateSnapshot() + const tabs = useTabsStateSnapshot() const [templates] = partition(SQL_TEMPLATES, { type: 'template' }) const [quickstarts] = partition(SQL_TEMPLATES, { type: 'quickstart' }) const { mutate: sendEvent } = useSendEventMutation() - const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'sql', owner_id: profile?.id }, subject: { id: profile?.id }, @@ -96,16 +96,14 @@ export function NewTab() { snapV2.addSnippet({ projectRef: ref, snippet }) snapV2.addNeedsSaving(snippet.id) - const store = getTabsStore(ref) - const tabId = `sql-${snippet.id}` - store.openTabs = [...store.openTabs, tabId] - store.tabsMap[tabId] = { + const tabId = createTabId('sql', { id: snippet.id }) + + tabs.addTab({ id: tabId, type: 'sql', label: name, metadata: { sqlId: snippet.id }, - } - store.activeTab = tabId + }) router.push(`/project/${ref}/sql/${snippet.id}`) } catch (error: any) { diff --git a/apps/studio/components/layouts/Tabs/RecentItems.tsx b/apps/studio/components/layouts/Tabs/RecentItems.tsx index 4fc55e3c4f4..bc684c59709 100644 --- a/apps/studio/components/layouts/Tabs/RecentItems.tsx +++ b/apps/studio/components/layouts/Tabs/RecentItems.tsx @@ -2,30 +2,26 @@ import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' import { AnimatePresence, motion } from 'framer-motion' import Link from 'next/link' -import { useSnapshot } from 'valtio' import { useParams } from 'common' import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' -import type { RecentItem } from 'state/recent-items' -import { getRecentItemsStore } from 'state/recent-items' -import { editorEntityTypes } from 'state/tabs' +import { editorEntityTypes, useTabsStateSnapshot } from 'state/tabs' import { useEditorType } from '../editors/EditorsLayout.hooks' dayjs.extend(relativeTime) export function RecentItems() { const { ref } = useParams() - const store = getRecentItemsStore(ref) - const recentItemsSnap = useSnapshot(store) + const tabs = useTabsStateSnapshot() const editor = useEditorType() - if (!recentItemsSnap?.items || !ref) return null + if (!tabs?.recentItems || !ref) return null // Filter items based on editor type const filteredItems = !editor - ? [...recentItemsSnap.items] - : recentItemsSnap.items.filter((item) => editorEntityTypes[editor].includes(item.type)) + ? [...tabs.recentItems] + : tabs.recentItems.filter((item) => editorEntityTypes[editor].includes(item.type)) const sortedItems = filteredItems.sort((a, b) => b.timestamp - a.timestamp) @@ -50,7 +46,7 @@ export function RecentItems() { ) : (
- {sortedItems.map((item: RecentItem, index: number) => ( + {sortedItems.map((item, index: number) => ( void }) => { const router = useRouter() - const { ref } = useParams() const currentSchema = (router.query.schema as string) || 'public' - + const tabs = useTabsStateSnapshot() const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id, }) @@ -73,7 +71,7 @@ export const SortableTab = ({ > makeTabPermanent(ref, tab.id)} + onDoubleClick={() => tabs.makeTabPermanent(tab.id)} className={cn( 'flex items-center gap-2 pl-3 pr-2.5 text-xs', 'bg-dash-sidebar/50 dark:bg-surface-100/50', diff --git a/apps/studio/components/layouts/Tabs/TabPreview.tsx b/apps/studio/components/layouts/Tabs/TabPreview.tsx index 1b271bb3401..44e608427dc 100644 --- a/apps/studio/components/layouts/Tabs/TabPreview.tsx +++ b/apps/studio/components/layouts/Tabs/TabPreview.tsx @@ -1,14 +1,12 @@ import { motion } from 'framer-motion' -import { useSnapshot } from 'valtio' import { useParams } from 'common' import { EntityTypeIcon } from 'components/ui/EntityTypeIcon' -import { getTabsStore } from 'state/tabs' +import { useTabsStateSnapshot } from 'state/tabs' export const TabPreview = ({ tab }: { tab: string }) => { const { ref } = useParams() - const store = getTabsStore(ref) - const tabs = useSnapshot(store) + const tabs = useTabsStateSnapshot() const tabData = tabs.tabsMap[tab] diff --git a/apps/studio/components/layouts/Tabs/Tabs.tsx b/apps/studio/components/layouts/Tabs/Tabs.tsx index ef65b70fce2..471d3496424 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.tsx +++ b/apps/studio/components/layouts/Tabs/Tabs.tsx @@ -15,14 +15,7 @@ import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { TabsUpdateTooltip } from 'components/interfaces/App/FeaturePreview/TableEditorTabs' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useAppStateSnapshot } from 'state/app-state' -import { - editorEntityTypes, - handleTabClose, - handleTabDragEnd, - handleTabNavigation, - useTabsStore, - type Tab, -} from 'state/tabs' +import { editorEntityTypes, useTabsStateSnapshot, type Tab } from 'state/tabs' import { cn, Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' import { useEditorType } from '../editors/EditorsLayout.hooks' import { CollapseButton } from './CollapseButton' @@ -35,7 +28,7 @@ export const EditorTabs = () => { const appSnap = useAppStateSnapshot() const editor = useEditorType() - const tabs = useTabsStore(ref) + const tabs = useTabsStateSnapshot() const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -68,7 +61,7 @@ export const EditorTabs = () => { const newIndex = tabs.openTabs.indexOf(over.id.toString()) if (oldIndex !== newIndex) { - handleTabDragEnd(ref, oldIndex, newIndex, active.id.toString(), router) + tabs.handleTabDragEnd(oldIndex, newIndex, active.id.toString(), router) } } @@ -78,11 +71,11 @@ export const EditorTabs = () => { appSnap.setDashboardHistory(ref, editor === 'table' ? 'editor' : editor, undefined) } } - handleTabClose({ ref, id, router, editor, onClearDashboardHistory }) + tabs.handleTabClose({ id, router, editor, onClearDashboardHistory }) } const handleTabChange = (id: string) => { - handleTabNavigation(ref, id, router) + tabs.handleTabNavigation(id, router) } return ( diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts index 9fc1e1225c3..de63834fda0 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts @@ -1,122 +1,107 @@ import { Entity } from 'data/entity-types/entity-types-infinite-query' -import { - getRecentItemsByType, - getRecentItemsStore, - RecentItem, - removeRecentItems, -} from 'state/recent-items' -import { createTabId, editorEntityTypes, getTabsStore, removeTabs, updateTab } from 'state/tabs' +import useLatest from 'hooks/misc/useLatest' +import { useCallback } from 'react' +import { createTabId, editorEntityTypes, useTabsStateSnapshot } from 'state/tabs' -export const tableEditorTabsCleanUp = ({ - ref, - schemas, - entities, -}: { - ref: string - schemas: string[] - entities: Entity[] -}) => { - // Identity all entities by tab ID - const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id })) +export function useTableEditorTabsCleanUp() { + const tabs = useTabsStateSnapshot() + const tabMapRef = useLatest(tabs.tabsMap) + const recentItemsRef = useLatest(tabs.recentItems) + const openTabsRef = useLatest(tabs.openTabs) - // Remove any recent items that no longer exists in the database - const recentItemsStore = getRecentItemsStore(ref) + return useCallback(({ schemas, entities }: { schemas: string[]; entities: Entity[] }) => { + // Identity all entities by tab ID + const entitiesById: string[] = entities.map((x: Entity) => createTabId(x.type, { id: x.id })) - const recentItemsFilteredToSchemas: string[] = [] - for (const schema of schemas) { - recentItemsFilteredToSchemas.push( - ...recentItemsStore?.items - ?.filter((x: RecentItem) => x.metadata?.schema === schema) - .map((x) => x.id) - ) - } + const recentItemsFilteredToSchemas: string[] = [] + for (const schema of schemas) { + recentItemsFilteredToSchemas.push( + ...recentItemsRef.current.filter((x) => x.metadata?.schema === schema).map((x) => x.id) + ) + } - const recentItemsToRemove = [ - ...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)), - ] + const recentItemsToRemove = [ + ...recentItemsFilteredToSchemas.filter((entityId) => !entitiesById.includes(entityId)), + ] - removeRecentItems(ref, recentItemsToRemove) + tabs.removeRecentItems(recentItemsToRemove) - // Close any tabs which might have been opened but no longer exists in the database - const tabsStore = getTabsStore(ref) + const tabsFilteredToSchemas: string[] = [] + for (const schema of schemas) { + tabsFilteredToSchemas.push( + ...openTabsRef.current.filter((tabId) => { + const tab = tabMapRef.current[tabId] + return tab.metadata?.schema === schema + }) + ) + } - const tabsFilteredToSchemas: string[] = [] - for (const schema of schemas) { - tabsFilteredToSchemas.push( - ...tabsStore?.openTabs?.filter((tabId) => { - const tab = tabsStore.tabsMap[tabId] - return tab.metadata?.schema === schema - }) - ) - } + const tableEditorTabsToBeCleaned = [ + ...tabsFilteredToSchemas.filter( + (id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id) + ), + ] - const tableEditorTabsToBeCleaned = [ - ...tabsFilteredToSchemas.filter( - (id) => !id.startsWith('sql') && !id.startsWith('schema') && !entitiesById.includes(id) - ), - ] + // perform tabs cleanup + tabs.removeTabs(tableEditorTabsToBeCleaned) - // perform tabs cleanup - removeTabs(ref, tableEditorTabsToBeCleaned) + // [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not + // As the entity could've been renamed outside of the table editor + // e.g Using the SQL editor to rename the entity + const openTabs = openTabsRef.current + .map((id) => tabMapRef.current[id]) + .filter((tab) => editorEntityTypes['table']?.includes(tab.type)) - // [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not - // As the entity could've been renamed outside of the table editor - // e.g Using the SQL editor to rename the entity - const openTabs = tabsStore.openTabs - .map((id) => tabsStore.tabsMap[id]) - .filter((tab) => editorEntityTypes['table']?.includes(tab.type)) - - openTabs.forEach((tab) => { - const entity = entities?.find((x) => tab.metadata?.tableId === x.id) - if (!!entity && entity.name !== tab.label) updateTab(ref, tab.id, { label: entity.name }) - }) + openTabs.forEach((tab) => { + const entity = entities?.find((x) => tab.metadata?.tableId === x.id) + if (!!entity && entity.name !== tab.label) tabs.updateTab(tab.id, { label: entity.name }) + }) + }, []) } -export const sqlEditorTabsCleanup = ({ - ref, - snippets, -}: { - ref: string - snippets: { id: string; type: string; name: string }[] -}) => { - // these are tabs that are static content - // these canot be removed from localstorage based on this query request - const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts'] +export function useSqlEditorTabsCleanup() { + const tabs = useTabsStateSnapshot() + const tabMapRef = useLatest(tabs.tabsMap) + const openTabsRef = useLatest(tabs.openTabs) - // Identify all SQL snippets / content by their tab ids - const currentContentIds = [ - ...snippets - .filter((content) => content.type === 'sql') - .map((content) => createTabId('sql', { id: content.id })), - // append ignored tab IDs - ...IGNORED_TAB_IDS, - ] + return useCallback(({ snippets }: { snippets: { id: string; type: string; name: string }[] }) => { + // these are tabs that are static content + // these canot be removed from localstorage based on this query request + const IGNORED_TAB_IDS = ['sql-templates', 'sql-quickstarts'] - // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session) - const tabsStore = getTabsStore(ref) - const snippetTabsToBeCleaned = tabsStore.openTabs.filter( - (id: string) => id.startsWith('sql') && !currentContentIds.includes(id) - ) - removeTabs(ref, snippetTabsToBeCleaned) + // Identify all SQL snippets / content by their tab ids + const currentContentIds = [ + ...snippets + .filter((content) => content.type === 'sql') + .map((content) => createTabId('sql', { id: content.id })), + // append ignored tab IDs + ...IGNORED_TAB_IDS, + ] - // Remove any recent items that might no longer be existing (removed outside of the dashboard session) - const recentItems = getRecentItemsByType(ref, 'sql') - removeRecentItems( - ref, - recentItems - ? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id) - : [] - ) + // Remove any snippet tabs that might no longer be existing (removed outside of the dashboard session) + const snippetTabsToBeCleaned = openTabsRef.current.filter( + (id: string) => id.startsWith('sql') && !currentContentIds.includes(id) + ) + tabs.removeTabs(snippetTabsToBeCleaned) - // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not - // As the snippets name could've been updated outside of the SQL Editor session - // e.g for a shared snippet, the owner could've updated the name of the snippet - const openSqlTabs = tabsStore.openTabs - .map((id) => tabsStore.tabsMap[id]) - .filter((tab) => editorEntityTypes['sql']?.includes(tab.type)) + // Remove any recent items that might no longer be existing (removed outside of the dashboard session) + const recentItems = tabs.getRecentItemsByType('sql') + tabs.removeRecentItems( + recentItems + ? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id) + : [] + ) - openSqlTabs.forEach((tab) => { - const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id) - if (!!snippet && snippet.name !== tab.label) updateTab(ref, tab.id, { label: snippet.name }) - }) + // [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not + // As the snippets name could've been updated outside of the SQL Editor session + // e.g for a shared snippet, the owner could've updated the name of the snippet + const openSqlTabs = openTabsRef.current + .map((id) => tabMapRef.current[id]) + .filter((tab) => editorEntityTypes['sql']?.includes(tab.type)) + + openSqlTabs.forEach((tab) => { + const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id) + if (!!snippet && snippet.name !== tab.label) tabs.updateTab(tab.id, { label: snippet.name }) + }) + }, []) } diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx index da2077944bf..8e4bab3f158 100644 --- a/apps/studio/pages/project/[ref]/editor/[id].tsx +++ b/apps/studio/pages/project/[ref]/editor/[id].tsx @@ -9,13 +9,13 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout' import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu' import { useTableEditorQuery } from 'data/table-editor/table-editor-query' -import { addTab, createTabId, getTabsStore } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import type { NextPageWithLayout } from 'types' const TableEditorPage: NextPageWithLayout = () => { const { id: _id, ref: projectRef } = useParams() const id = _id ? Number(_id) : undefined - const store = getTabsStore(projectRef) + const store = useTabsStateSnapshot() const { project } = useProjectContext() const { data: selectedTable, isLoading } = useTableEditorQuery({ @@ -37,7 +37,7 @@ const TableEditorPage: NextPageWithLayout = () => { if (isTableEditorTabsEnabled && selectedTable && projectRef) { const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id }) if (!store.tabsMap[tabId]) { - addTab(projectRef, { + store.addTab({ id: tabId, type: selectedTable.entity_type, label: selectedTable.name, @@ -49,7 +49,7 @@ const TableEditorPage: NextPageWithLayout = () => { }) } else { // If tab already exists, just make it active - store.activeTab = tabId + store.makeTabActive(tabId) } } }, [selectedTable, id, projectRef, isTableEditorTabsEnabled]) diff --git a/apps/studio/pages/project/[ref]/editor/index.tsx b/apps/studio/pages/project/[ref]/editor/index.tsx index e1a129c3808..b970f2e90a1 100644 --- a/apps/studio/pages/project/[ref]/editor/index.tsx +++ b/apps/studio/pages/project/[ref]/editor/index.tsx @@ -10,13 +10,13 @@ import { NewTab } from 'components/layouts/Tabs/NewTab' import { useRouter } from 'next/router' import { useEffect } from 'react' import { useAppStateSnapshot } from 'state/app-state' -import { editorEntityTypes, getTabsStore } from 'state/tabs' +import { editorEntityTypes, useTabsStateSnapshot } from 'state/tabs' import type { NextPageWithLayout } from 'types' const TableEditorPage: NextPageWithLayout = () => { const router = useRouter() const { ref: projectRef } = useParams() - const tabStore = getTabsStore(projectRef) + const tabStore = useTabsStateSnapshot() const appSnap = useAppStateSnapshot() const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled() diff --git a/apps/studio/pages/project/[ref]/sql/[id].tsx b/apps/studio/pages/project/[ref]/sql/[id].tsx index 8b37d86603f..97b26a634a1 100644 --- a/apps/studio/pages/project/[ref]/sql/[id].tsx +++ b/apps/studio/pages/project/[ref]/sql/[id].tsx @@ -11,7 +11,7 @@ import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu' import { useContentIdQuery } from 'data/content/content-id-query' import { useAppStateSnapshot } from 'state/app-state' import { SnippetWithContent, useSnippets, useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' -import { addTab, createTabId } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import type { NextPageWithLayout } from 'types' const SqlEditor: NextPageWithLayout = () => { @@ -20,6 +20,7 @@ const SqlEditor: NextPageWithLayout = () => { const appSnap = useAppStateSnapshot() const snapV2 = useSqlEditorV2StateSnapshot() + const tabs = useTabsStateSnapshot() const allSnippets = useSnippets(ref!) @@ -65,7 +66,7 @@ const SqlEditor: NextPageWithLayout = () => { const tabId = createTabId('sql', { id }) const snippet = allSnippets.find((x) => x.id === id) - addTab(ref, { + tabs.addTab({ id: tabId, type: 'sql', label: snippet?.name || 'Untitled Query', diff --git a/apps/studio/pages/project/[ref]/sql/index.tsx b/apps/studio/pages/project/[ref]/sql/index.tsx index 44e2019cf0a..633f87e8738 100644 --- a/apps/studio/pages/project/[ref]/sql/index.tsx +++ b/apps/studio/pages/project/[ref]/sql/index.tsx @@ -8,13 +8,13 @@ import { NewTab } from 'components/layouts/Tabs/NewTab' import { useRouter } from 'next/router' import { useEffect } from 'react' import { useAppStateSnapshot } from 'state/app-state' -import { getTabsStore } from 'state/tabs' +import { useTabsStateSnapshot } from 'state/tabs' import type { NextPageWithLayout } from 'types' const TableEditorPage: NextPageWithLayout = () => { const router = useRouter() const { ref: projectRef } = useParams() - const store = getTabsStore(projectRef) + const store = useTabsStateSnapshot() const appSnap = useAppStateSnapshot() const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() diff --git a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx index 71e9e91bf1d..bdeffe57588 100644 --- a/apps/studio/pages/project/[ref]/sql/quickstarts.tsx +++ b/apps/studio/pages/project/[ref]/sql/quickstarts.tsx @@ -8,12 +8,13 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu' -import { addTab, createTabId } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import type { NextPageWithLayout } from 'types' const SqlQuickstarts: NextPageWithLayout = () => { const router = useRouter() const { ref } = useParams<{ ref: string }>() + const tabs = useTabsStateSnapshot() const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() @@ -23,7 +24,7 @@ const SqlQuickstarts: NextPageWithLayout = () => { const tabId = createTabId('sql', { id: 'quickstarts' }) - addTab(ref, { + tabs.addTab({ id: tabId, type: 'sql', label: 'Quickstarts', diff --git a/apps/studio/pages/project/[ref]/sql/templates.tsx b/apps/studio/pages/project/[ref]/sql/templates.tsx index e481286527e..45920a90c96 100644 --- a/apps/studio/pages/project/[ref]/sql/templates.tsx +++ b/apps/studio/pages/project/[ref]/sql/templates.tsx @@ -8,12 +8,13 @@ import DefaultLayout from 'components/layouts/DefaultLayout' import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout' import SQLEditorLayout from 'components/layouts/SQLEditorLayout/SQLEditorLayout' import { SQLEditorMenu } from 'components/layouts/SQLEditorLayout/SQLEditorMenu' -import { addTab, createTabId } from 'state/tabs' +import { createTabId, useTabsStateSnapshot } from 'state/tabs' import type { NextPageWithLayout } from 'types' const SqlTemplates: NextPageWithLayout = () => { const router = useRouter() const { ref } = useParams<{ ref: string }>() + const tabs = useTabsStateSnapshot() const isSqlEditorTabsEnabled = useIsSQLEditorTabsEnabled() @@ -23,7 +24,7 @@ const SqlTemplates: NextPageWithLayout = () => { const tabId = createTabId('sql', { id: 'templates' }) - addTab(ref, { + tabs.addTab({ id: tabId, type: 'sql', label: 'Templates', diff --git a/apps/studio/state/recent-items.ts b/apps/studio/state/recent-items.ts deleted file mode 100644 index a6272e3d3b4..00000000000 --- a/apps/studio/state/recent-items.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { partition } from 'lodash' -import { proxy, subscribe } from 'valtio' -import { editorEntityTypes, Tab, TabType } from './tabs' - -/** - * [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores - * where we have contexts and such - ideally we need to refactor this for consistency - * and the main benefit from our usual way of writing stores is that we do not need to pass - * in the project ref to all the functions, as the stores are written to be scoped to the project - * - * So do not use this as reference for writing new valtio stores - refer to database-selector instead - */ - -// Define the key for storing recent items in localStorage -const RECENT_ITEMS_KEY = 'supabase_recent_items' -// Define the maximum number of recent items to store -const MAX_RECENT_ITEMS = 8 - -const getStorageKey = (ref: string) => `${RECENT_ITEMS_KEY}_${ref}` - -// Define the structure of a recent item -export interface RecentItem { - id: string // Unique identifier for the item - type: TabType // Type of the tab (e.g., sql, table) - label: string // Label for the item - timestamp: number // Timestamp of when the item was added - metadata?: { - // Optional metadata associated with the item - schema?: string // Schema name (if applicable) - name?: string // Name of the item (if applicable) - tableId?: number // Table ID (if applicable) - sqlId?: string // SQL ID (if applicable) - } -} - -// Define the structure of the recent items state -interface RecentItems { - items: RecentItem[] // Each project just has an array of items -} - -interface RecentItemsStore { - [key: string]: RecentItems // Removed | undefined since we always initialize it -} - -const defaultState: RecentItems = { - items: [], -} - -export const recentItemsStore = proxy({}) - -export const getRecentItemsStore = (ref: string | undefined): RecentItems => { - if (!ref) return proxy(defaultState) - if (!recentItemsStore[ref]) { - const stored = localStorage.getItem(getStorageKey(ref)) - console.debug('[Recent Items] Loading stored items:', stored) - - try { - const parsed = stored ? JSON.parse(stored) : defaultState - // Validate the shape of the data - if (!parsed.items || !Array.isArray(parsed.items)) { - console.warn('[Recent Items] Invalid stored data, using default') - recentItemsStore[ref] = proxy({ ...defaultState }) - } else { - recentItemsStore[ref] = proxy(parsed) - } - } catch (error) { - console.error('[Recent Items] Failed to parse stored items:', error) - recentItemsStore[ref] = proxy({ ...defaultState }) - } - } - - return recentItemsStore[ref] -} - -// Subscribe to changes for each ref and save to localStorage -if (typeof window !== 'undefined') { - subscribe(recentItemsStore, () => { - Object.entries(recentItemsStore).forEach(([ref, state]) => { - try { - localStorage.setItem(getStorageKey(ref), JSON.stringify(state)) - } catch (error) { - console.error('Failed to save recent items to localStorage:', error) - } - }) - }) -} - -// Function to add a recent item -export const addRecentItem = (ref: string | undefined, tab: Tab) => { - if (!ref) return - const store = getRecentItemsStore(ref) - // Check if an item with the same ID already exists - const existingItem = store.items.find((item) => item.id === tab.id) - - if (existingItem) { - // If it exists, update its timestamp - existingItem.timestamp = Date.now() - return // Exit the function - } - - // If it doesn't exist, create and add a new item - const recentItem: RecentItem = { - id: tab.id, // Set the ID - type: tab.type, // Set the type - label: tab.label || 'Untitled', // Set the label or default to 'Untitled' - timestamp: Date.now(), // Set the current timestamp - metadata: tab.metadata, // Set the metadata - } - - // Add the new recent item to the beginning of the list - store.items.unshift(recentItem) - - // Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type - const [itemsOfSameType, itemsOfDifferentType] = partition(store.items, (item) => { - if (editorEntityTypes.table.includes(item.type)) return item - }) - store.items = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType] -} - -// Function to clear all recent items -export const clearRecentItems = (ref: string | undefined) => { - if (!ref) return - const store = getRecentItemsStore(ref) - store.items = [] // Set items to an empty array -} - -// Function to remove a specific recent item by its ID -export const removeRecentItem = (ref: string | undefined, itemId: string) => { - if (!ref) return - const store = getRecentItemsStore(ref) - store.items = store.items.filter((item) => item.id !== itemId) -} - -// Function to remove multiple recent items by their IDs -export const removeRecentItems = (ref: string | undefined, itemIds: string[]) => { - if (!ref) return - const store = getRecentItemsStore(ref) - store.items = store.items.filter((item) => !itemIds.includes(item.id)) -} - -// Function to remove recent items by their type -export const removeRecentItemsByType = (ref: string | undefined, type: TabType) => { - if (!ref) return - const store = getRecentItemsStore(ref) - store.items = store.items.filter((item) => item.type !== type) -} - -// Function to get all recent items -export const getRecentItems = (ref: string | undefined) => { - if (!ref) return - const store = getRecentItemsStore(ref) - return store.items -} - -// Function to get recent items by their type -export const getRecentItemsByType = (ref: string | undefined, type: TabType) => { - if (!ref) return - const store = getRecentItemsStore(ref) - return store.items.filter((item) => item.type === type) -} diff --git a/apps/studio/state/tabs.ts b/apps/studio/state/tabs.ts deleted file mode 100644 index c04c828116a..00000000000 --- a/apps/studio/state/tabs.ts +++ /dev/null @@ -1,404 +0,0 @@ -import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' -import { NextRouter } from 'next/router' -import { ReactNode } from 'react' -import { proxy, subscribe, useSnapshot } from 'valtio' -import { addRecentItem } from './recent-items' - -/** - * [TODO REFACTOR] Joshen: JFYI this differs from the way that we usually write our stores - * where we have contexts and such - ideally we need to refactor this for consistency - * and the main benefit from our usual way of writing stores is that we do not need to pass - * in the project ref to all the functions, as the stores are written to be scoped to the project - * - * So do not use this as reference for writing new valtio stores - refer to database-selector instead - */ - -// Object mapping editor types to their corresponding tab types -export const editorEntityTypes = { - table: ['r', 'v', 'm', 'f', 'p'], - sql: ['sql'], -} - -// Define the type of tabs available in the application -export type TabType = ENTITY_TYPE | 'sql' - -type CreateTabIdParams = { - r: { id: number } - v: { id: number } - m: { id: number } - f: { id: number } - p: { id: number } - sql: { id: string } - schema: { schema: string } - view: never - function: never - new: never -} - -export interface Tab { - id: string // Unique identifier for the tab - type: TabType // Type of the tab, which can be an entity type or specific string values - label?: string // Optional label for the tab, displayed to the user - icon?: ReactNode // Optional icon associated with the tab - metadata?: { - // Optional metadata related to the tab - schema?: string // Optional schema name associated with the tab - name?: string // Optional name of the entity represented by the tab - tableId?: number // Optional ID of the table associated with the tab - sqlId?: string // Optional ID of the SQL query associated with the tab - scrollTop?: number // Optional scroll top of the tab (Currently just for SQL query) - } - isPreview?: boolean // Optional flag indicating if the tab is in preview mode - createdAt?: Date // Optional timestamp for when the tab was created - updatedAt?: Date // Optional timestamp for when the tab was last updated -} - -// Interface representing the state of tabs -interface TabsState { - activeTab: string | null - openTabs: string[] - tabsMap: { [key: string]: Tab } - previewTabId?: string -} - -// Map of tab states for different references -interface TabsStateMap { - [ref: string]: TabsState -} - -const STORAGE_KEY = 'supabase_studio_tabs' -const getStorageKey = (ref: string) => `${STORAGE_KEY}_${ref}` - -const defaultState: TabsState = { - openTabs: [], - activeTab: null, - tabsMap: {}, -} - -// Store now holds states for multiple refs -export const tabsStore = proxy({}) - -// Helper to get/create state for a specific ref -export const getTabsStore = (ref?: string): TabsState => { - if (!ref) return proxy(defaultState) - if (!tabsStore[ref]) { - const stored = localStorage.getItem(getStorageKey(ref)) - console.debug('[Tabs] Loading stored tabs:', stored) - try { - const parsed = stored ? JSON.parse(stored) : defaultState - if ( - !parsed.openTabs || - !Array.isArray(parsed.openTabs) || - !parsed.tabsMap || - typeof parsed.tabsMap !== 'object' - ) { - console.warn('[Tabs] Invalid stored data, using default') - tabsStore[ref] = proxy({ ...defaultState }) - } else { - tabsStore[ref] = proxy(parsed) - } - } catch (error) { - console.error('[Tabs] Failed to parse stored tabs:', error) - tabsStore[ref] = proxy({ ...defaultState }) - } - } - - return tabsStore[ref] -} - -export const useTabsStore = (ref?: string) => { - const store = getTabsStore(ref) - return useSnapshot(store) -} - -// Subscribe to changes for each ref and save to localStorage -if (typeof window !== 'undefined') { - subscribe(tabsStore, () => { - Object.entries(tabsStore).forEach(([ref, state]) => { - try { - localStorage.setItem(getStorageKey(ref), JSON.stringify(state)) - } catch (error) { - console.error('Failed to save tabs to localStorage:', error) - } - }) - }) -} - -// Function to add a new tab to the store -export const addTab = (ref: string | undefined, tab: Tab) => { - if (!ref) return - const store = getTabsStore(ref) - - // If tab exists and is active, don't do anything - if (store.tabsMap[tab.id] && store.activeTab === tab.id) { - return - } - - // If tab exists but isn't active, just make it active - if (store.tabsMap[tab.id]) { - store.activeTab = tab.id - if (!tab.isPreview) addRecentItem(ref, tab) - return - } - - // If this tab should be permanent, add it normally - if (tab.isPreview === false) { - store.openTabs = [...store.openTabs, tab.id] - store.tabsMap[tab.id] = tab - store.activeTab = tab.id - // Add to recent items when creating permanent tab - addRecentItem(ref, tab) - return - } - - // Remove any existing preview tab - if (store.previewTabId) { - store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId) - delete store.tabsMap[store.previewTabId] - } - - // Add new preview tab - store.tabsMap[tab.id] = { ...tab, isPreview: true } - store.openTabs = [...store.openTabs, tab.id] - store.previewTabId = tab.id - store.activeTab = tab.id -} - -// Function to remove a tab from the store -// this is used for removing tabs from the localstorage state -// for handling a manual tab removal with a close action, use handleTabClose() -export const removeTab = (ref: string | undefined, id: string) => { - const store = getTabsStore(ref) - const idx = store.openTabs.indexOf(id) - store.openTabs = store.openTabs.filter((tabId) => tabId !== id) - delete store.tabsMap[id] - - // Update active tab if the removed tab was active - if (id === store.activeTab) { - store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null - } -} - -// Function to remove multiple tabs from the store -// this is used for removing tabs from the localstorage state -// for handling a manual tab removal with a close action, use handleTabClose() -export const removeTabs = (ref: string | undefined, ids: string[]) => { - if (!ref) return - if (!ids.length) return - - ids.forEach((id) => removeTab(ref, id)) -} - -export const updateTab = ( - ref: string, - id: string, - updates: { label?: string; scrollTop?: number } -) => { - const store = getTabsStore(ref) - - if (!!store.tabsMap[id]) { - if ('label' in updates) { - store.tabsMap[id].label = updates.label - } - if ('scrollTop' in updates && store.tabsMap[id].metadata) { - store.tabsMap[id].metadata.scrollTop = updates.scrollTop - } - } -} - -// Function to reorder tabs in the store -export const reorderTabs = (ref: string | undefined, oldIndex: number, newIndex: number) => { - if (!ref) return - const store = getTabsStore(ref) - const newOpenTabs = [...store.openTabs] - const [removedTab] = newOpenTabs.splice(oldIndex, 1) - newOpenTabs.splice(newIndex, 0, removedTab) - store.openTabs = newOpenTabs -} - -// Function to make a tab permanent -export const makeTabPermanent = (ref: string | undefined, tabId: string) => { - if (!ref) return - const store = getTabsStore(ref) - const tab = store.tabsMap[tabId] - - if (tab?.isPreview) { - tab.isPreview = false - store.previewTabId = undefined - // Add to recent items when preview tab becomes permanent - addRecentItem(ref, tab) - } -} - -// make the active tab permanent if it is a preview -export const makeActiveTabPermanent = (ref?: string) => { - if (!ref) return false - const store = getTabsStore(ref) - - if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) { - makeTabPermanent(ref, store.activeTab) - return true - } - return false -} - -// handle navigation to a specific tab -export const handleTabNavigation = (ref: string | undefined, id: string, router: NextRouter) => { - if (!ref) return - - const store = getTabsStore(ref) - const tab = store.tabsMap[id] - if (!tab) return - - store.activeTab = id - - // Add to recent items when navigating to a non-preview, non-new tab - if (!tab.isPreview) addRecentItem(ref, tab) - - switch (tab.type) { - case 'sql': - const schema = (router.query.schema as string) || 'public' - router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`) - break - case 'r': - case 'v': - case 'm': - case 'f': - case 'p': - router.push( - `/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}` - ) - break - } -} - -// Function to handle closing a tab -export const handleTabClose = ({ - ref, - id, - router, - editor, - onClose, - onClearDashboardHistory, -}: { - ref: string | undefined - id: string - router: NextRouter - editor?: 'sql' | 'table' - onClose?: (id: string) => void - onClearDashboardHistory: () => void -}) => { - if (!ref) return - - const store = getTabsStore(ref) - const tabBeingClosed = store.tabsMap[id] - const tabsAfterClosing = Object.values(store.tabsMap).filter((tab) => tab.id !== id) - - const nextTabId = !editor - ? undefined - : tabsAfterClosing.filter((tab) => { - return editorEntityTypes[editor]?.includes(tab.type) - })[0]?.id - - const { [id]: value, ...otherTabs } = store.tabsMap - store.tabsMap = otherTabs - - if (tabBeingClosed) { - const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id) - store.openTabs = updatedOpenTabs - } - - // Remove the preview tab if it matches the tab being closed - if (store.previewTabId === id) { - store.previewTabId = undefined - } - - // [Joshen] Only navigate away if we're closing the tab that's currently in focus - if (store.activeTab === id || id === 'new') { - if (nextTabId) { - store.activeTab = nextTabId - handleTabNavigation(ref, nextTabId, router) - } else { - onClearDashboardHistory() - - // If no tabs of same type, go to the home of the current section - switch (tabBeingClosed?.type) { - case 'sql': - router.push(`/project/${router.query.ref}/sql`) - break - case 'r': - case 'v': - case 'm': - case 'f': - case 'p': - router.push(`/project/${router.query.ref}/editor`) - break - default: - router.push(`/project/${router.query.ref}/${editor}`) - } - } - } - - onClose?.(id) -} - -// Function to handle the end of a tab drag event -export const handleTabDragEnd = ( - ref: string | undefined, - oldIndex: number, - newIndex: number, - tabId: string, - router: any -) => { - if (!ref) return - const store = getTabsStore(ref) - // Make permanent if needed - const draggedTab = store.tabsMap[tabId] - if (draggedTab?.isPreview) { - makeTabPermanent(ref, tabId) - } - - // Reorder tabs - const newOpenTabs = [...store.openTabs] - newOpenTabs.splice(oldIndex, 1) - newOpenTabs.splice(newIndex, 0, tabId) - - store.openTabs = newOpenTabs - store.activeTab = tabId - - // Handle navigation - handleTabNavigation(ref, tabId, router) -} - -// Function to create a unique tab ID based on type and parameters -export function createTabId(type: T, params: CreateTabIdParams[T]): string { - switch (type) { - case 'r': - return `r-${(params as CreateTabIdParams['r']).id}` - case 'v': - return `v-${(params as CreateTabIdParams['v']).id}` - case 'm': - return `m-${(params as CreateTabIdParams['m']).id}` - case 'f': - return `f-${(params as CreateTabIdParams['f']).id}` - case 'p': - return `p-${(params as CreateTabIdParams['p']).id}` - case 'sql': - return `sql-${(params as CreateTabIdParams['sql']).id}` - default: - return '' - } -} - -// Function to remove tabs based on their editor type -export function removeTabsByEditor(ref: string | undefined, type: 'table' | 'sql') { - if (!ref) return - const store = getTabsStore(ref) - - // Get tab IDs to remove - const tabIdsToRemove = store.openTabs.filter((id) => { - return editorEntityTypes[type].some((entityType) => id.startsWith(`${entityType}-`)) - }) - - removeTabs(ref, tabIdsToRemove) -} diff --git a/apps/studio/state/tabs.tsx b/apps/studio/state/tabs.tsx new file mode 100644 index 00000000000..b6567fca5a0 --- /dev/null +++ b/apps/studio/state/tabs.tsx @@ -0,0 +1,471 @@ +import { useConstant } from 'common' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { partition } from 'lodash' +import { NextRouter } from 'next/router' +import { createContext, PropsWithChildren, ReactNode, useContext, useEffect } from 'react' +import { proxy, subscribe, useSnapshot } from 'valtio' + +export const editorEntityTypes = { + table: ['r', 'v', 'm', 'f', 'p'], + sql: ['sql'], +} + +export type TabType = ENTITY_TYPE | 'sql' + +type CreateTabIdParams = { + r: { id: number } + v: { id: number } + m: { id: number } + f: { id: number } + p: { id: number } + sql: { id: string } + schema: { schema: string } + view: never + function: never + new: never +} + +export interface Tab { + id: string + type: TabType + label?: string + icon?: ReactNode + metadata?: { + schema?: string + name?: string + tableId?: number + sqlId?: string + scrollTop?: number + } + isPreview?: boolean + createdAt?: Date + updatedAt?: Date +} + +const MAX_RECENT_ITEMS = 8 + +export interface RecentItem { + id: string + type: TabType + label: string + timestamp: number + metadata?: { + schema?: string + name?: string + tableId?: number + sqlId?: string + } +} + +const RECENT_ITEMS_STORAGE_KEY = 'supabase_recent_items' +const getRecentItemsStorageKey = (ref: string) => `${RECENT_ITEMS_STORAGE_KEY}_${ref}` + +function getSavedRecentItems(ref: string): RecentItem[] { + if (typeof window === 'undefined' || !ref) return [] + + const stored = localStorage.getItem(getRecentItemsStorageKey(ref)) + + try { + return JSON.parse(stored ?? '{"items": []}').items + } catch (error) { + return [] + } +} + +const DEFAULT_TABS_STATE = { + activeTab: null as string | null, + openTabs: [] as string[], + tabsMap: {} as { [key: string]: Tab }, + previewTabId: undefined as string | undefined, +} +const TABS_STORAGE_KEY = 'supabase_studio_tabs' +const getTabsStorageKey = (ref: string) => `${TABS_STORAGE_KEY}_${ref}` + +function getSavedTabs(ref: string) { + if (typeof window === 'undefined' || !ref) return DEFAULT_TABS_STATE + + const stored = localStorage.getItem(getTabsStorageKey(ref)) + + if (!stored) return DEFAULT_TABS_STATE + + try { + const parsed = JSON.parse( + stored ?? JSON.stringify(DEFAULT_TABS_STATE) + ) as typeof DEFAULT_TABS_STATE + + if ( + !parsed.openTabs || + !Array.isArray(parsed.openTabs) || + !parsed.tabsMap || + typeof parsed.tabsMap !== 'object' + ) { + return DEFAULT_TABS_STATE + } + + return parsed + } catch (error) { + return DEFAULT_TABS_STATE + } +} + +function createTabsState(projectRef: string) { + const recentItems = getSavedRecentItems(projectRef) + const { openTabs, activeTab, tabsMap, previewTabId } = getSavedTabs(projectRef) + + const store = proxy({ + // RECENT ITEMS + recentItems, + + addRecentItem: (tab: Tab) => { + // Check if an item with the same ID already exists + const existingItem = store.recentItems.find((item) => item.id === tab.id) + + if (existingItem) { + // If it exists, update its timestamp + existingItem.timestamp = Date.now() + return // Exit the function + } + + // If it doesn't exist, create and add a new item + const recentItem: RecentItem = { + id: tab.id, // Set the ID + type: tab.type, // Set the type + label: tab.label || 'Untitled', // Set the label or default to 'Untitled' + timestamp: Date.now(), // Set the current timestamp + metadata: tab.metadata, // Set the metadata + } + + // Add the new recent item to the beginning of the list + store.recentItems.unshift(recentItem) + + // Ensure that there's only up to max of MAX_RECENT_ITEMS items per tab type + const [itemsOfSameType, itemsOfDifferentType] = partition(store.recentItems, (item) => { + if (editorEntityTypes.table.includes(item.type)) return item + }) + store.recentItems = [...itemsOfSameType.slice(0, MAX_RECENT_ITEMS), ...itemsOfDifferentType] + }, + clearRecentItems: () => { + store.recentItems = [] + }, + removeRecentItem: (itemId: string) => { + store.recentItems = store.recentItems.filter((item) => item.id !== itemId) + }, + removeRecentItems: (itemIds: string[]) => { + store.recentItems = store.recentItems.filter((item) => !itemIds.includes(item.id)) + }, + removeRecentItemsByType: (type: TabType) => { + store.recentItems = store.recentItems.filter((item) => item.type !== type) + }, + + getRecentItemsByType: (type: TabType) => { + return store.recentItems.filter((item) => item.type === type) + }, + + // TABS + activeTab, + openTabs, + tabsMap, + previewTabId, + + addTab: (tab: Tab) => { + // If tab exists and is active, don't do anything + if (store.tabsMap[tab.id] && store.activeTab === tab.id) { + return + } + + // If tab exists but isn't active, just make it active + if (store.tabsMap[tab.id]) { + store.activeTab = tab.id + if (!tab.isPreview) store.addRecentItem(tab) + return + } + + // If this tab should be permanent, add it normally + if (tab.isPreview === false) { + store.openTabs = [...store.openTabs, tab.id] + store.tabsMap[tab.id] = tab + store.activeTab = tab.id + // Add to recent items when creating permanent tab + store.addRecentItem(tab) + return + } + + // Remove any existing preview tab + if (store.previewTabId) { + store.openTabs = store.openTabs.filter((id) => id !== store.previewTabId) + delete store.tabsMap[store.previewTabId] + } + + // Add new preview tab + store.tabsMap[tab.id] = { ...tab, isPreview: true } + store.openTabs = [...store.openTabs, tab.id] + store.previewTabId = tab.id + store.activeTab = tab.id + }, + updateTab: (id: string, updates: { label?: string; scrollTop?: number }) => { + if (!!store.tabsMap[id]) { + if ('label' in updates) { + store.tabsMap[id].label = updates.label + } + if ('scrollTop' in updates && store.tabsMap[id].metadata) { + store.tabsMap[id].metadata.scrollTop = updates.scrollTop + } + } + }, + // Function to remove a tab from the store + // this is used for removing tabs from the localstorage state + // for handling a manual tab removal with a close action, use handleTabClose() + removeTab: (id: string) => { + const idx = store.openTabs.indexOf(id) + store.openTabs = store.openTabs.filter((tabId) => tabId !== id) + delete store.tabsMap[id] + + // Update active tab if the removed tab was active + if (id === store.activeTab) { + store.activeTab = store.openTabs[idx - 1] || store.openTabs[idx + 1] || null + } + }, + + // Function to remove multiple tabs from the store + // this is used for removing tabs from the localstorage state + // for handling a manual tab removal with a close action, use handleTabClose() + removeTabs: (ids: string[]) => { + if (!ids.length) return + + ids.forEach((id) => store.removeTab(id)) + }, + reorderTabs: (oldIndex: number, newIndex: number) => { + const newOpenTabs = [...store.openTabs] + const [removedTab] = newOpenTabs.splice(oldIndex, 1) + newOpenTabs.splice(newIndex, 0, removedTab) + store.openTabs = newOpenTabs + }, + makeTabActive: (tabId: string) => { + const tab = store.tabsMap[tabId] + if (!tab) return + store.activeTab = tab.id + }, + makeTabPermanent: (tabId: string) => { + const tab = store.tabsMap[tabId] + + if (tab?.isPreview) { + tab.isPreview = false + store.previewTabId = undefined + // Add to recent items when preview tab becomes permanent + store.addRecentItem(tab) + } + }, + makeActiveTabPermanent: () => { + if (store.activeTab && store.tabsMap[store.activeTab]?.isPreview) { + store.makeTabPermanent(store.activeTab) + return true + } + return false + }, + + // TABS HANDLERS + + handleTabNavigation: (id: string, router: NextRouter) => { + const tab = store.tabsMap[id] + if (!tab) return + + store.activeTab = id + + // Add to recent items when navigating to a non-preview, non-new tab + if (!tab.isPreview) store.addRecentItem(tab) + + switch (tab.type) { + case 'sql': + const schema = (router.query.schema as string) || 'public' + router.push(`/project/${router.query.ref}/sql/${tab.metadata?.sqlId}?schema=${schema}`) + break + case 'r': + case 'v': + case 'm': + case 'f': + case 'p': + router.push( + `/project/${router.query.ref}/editor/${tab.metadata?.tableId}?schema=${tab.metadata?.schema}` + ) + break + } + }, + handleTabClose: ({ + id, + router, + editor, + onClose, + onClearDashboardHistory, + }: { + id: string + router: NextRouter + editor?: 'sql' | 'table' + onClose?: (id: string) => void + onClearDashboardHistory: () => void + }) => { + const tabBeingClosed = store.tabsMap[id] + const tabsAfterClosing = Object.values(store.tabsMap).filter((tab) => tab.id !== id) + + const nextTabId = !editor + ? undefined + : tabsAfterClosing.filter((tab) => { + return editorEntityTypes[editor]?.includes(tab.type) + })[0]?.id + + const { [id]: value, ...otherTabs } = store.tabsMap + store.tabsMap = otherTabs + + if (tabBeingClosed) { + const updatedOpenTabs = [...store.openTabs].filter((x) => x !== id) + store.openTabs = updatedOpenTabs + } + + // Remove the preview tab if it matches the tab being closed + if (store.previewTabId === id) { + store.previewTabId = undefined + } + + // [Joshen] Only navigate away if we're closing the tab that's currently in focus + if (store.activeTab === id || id === 'new') { + if (nextTabId) { + store.activeTab = nextTabId + store.handleTabNavigation(nextTabId, router) + } else { + onClearDashboardHistory() + + // If no tabs of same type, go to the home of the current section + switch (tabBeingClosed?.type) { + case 'sql': + router.push(`/project/${router.query.ref}/sql`) + break + case 'r': + case 'v': + case 'm': + case 'f': + case 'p': + router.push(`/project/${router.query.ref}/editor`) + break + default: + router.push(`/project/${router.query.ref}/${editor}`) + } + } + } + + onClose?.(id) + }, + handleTabDragEnd: (oldIndex: number, newIndex: number, tabId: string, router: any) => { + // Make permanent if needed + const draggedTab = store.tabsMap[tabId] + if (draggedTab?.isPreview) { + store.makeTabPermanent(tabId) + } + + // Reorder tabs + const newOpenTabs = [...store.openTabs] + newOpenTabs.splice(oldIndex, 1) + newOpenTabs.splice(newIndex, 0, tabId) + + store.openTabs = newOpenTabs + store.activeTab = tabId + + // Handle navigation + store.handleTabNavigation(tabId, router) + }, + }) + + return store +} + +export type TabsState = ReturnType + +export const TabsStateContext = createContext(createTabsState('')) + +export const TabsStateContextProvider = ({ + projectRef, + children, +}: PropsWithChildren<{ projectRef?: string }>) => { + const state = useConstant(() => createTabsState(projectRef ?? '')) + + useEffect(() => { + if (typeof window !== 'undefined' && projectRef) { + return subscribe(state, () => { + localStorage.setItem( + getTabsStorageKey(projectRef), + JSON.stringify({ + activeTab: state.activeTab, + openTabs: state.openTabs, + tabsMap: state.tabsMap, + previewTabId: state.previewTabId, + }) + ) + localStorage.setItem( + getRecentItemsStorageKey(projectRef), + JSON.stringify({ + items: state.recentItems, + }) + ) + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return {children} +} + +export const useTabsStateSnapshot = (options?: Parameters[1]) => { + const state = useContext(TabsStateContext) + return useSnapshot(state, options) +} + +export function createTabId(type: T, params: CreateTabIdParams[T]): string { + switch (type) { + case 'r': + return `r-${(params as CreateTabIdParams['r']).id}` + case 'v': + return `v-${(params as CreateTabIdParams['v']).id}` + case 'm': + return `m-${(params as CreateTabIdParams['m']).id}` + case 'f': + return `f-${(params as CreateTabIdParams['f']).id}` + case 'p': + return `p-${(params as CreateTabIdParams['p']).id}` + case 'sql': + return `sql-${(params as CreateTabIdParams['sql']).id}` + default: + return '' + } +} + +// Remove from local storage when feature flag is disabled +export function removeTabsByEditor(ref: string, editor: 'table' | 'sql') { + // Recent items + const recentItems = getSavedRecentItems(ref) + const filteredRecentItems = recentItems.filter((item) => + editor === 'sql' ? item.type !== 'sql' : item.type === 'sql' + ) + localStorage.setItem( + getRecentItemsStorageKey(ref), + JSON.stringify({ items: filteredRecentItems }) + ) + + // Tabs + const tabs = getSavedTabs(ref) + const filteredTabsMap = Object.fromEntries( + Object.entries(tabs.tabsMap).filter(([, tab]) => + editor === 'sql' ? tab.type !== 'sql' : tab.type === 'sql' + ) + ) + + const filteredOpenTabs = tabs.openTabs.filter((tabId) => filteredTabsMap[tabId]) + localStorage.setItem( + getTabsStorageKey(ref), + JSON.stringify({ + activeTab: filteredOpenTabs.includes(tabs.activeTab ?? '') + ? tabs.activeTab + : filteredOpenTabs[0] ?? null, + openTabs: filteredOpenTabs, + tabsMap: filteredTabsMap, + previewTabId: tabs.previewTabId, + }) + ) +}