import {createSelector, createSlice} from '@reduxjs/toolkit'; import type {PayloadAction} from '@reduxjs/toolkit'; import {v4 as uuidv4} from 'uuid'; import type {QueryAction} from '../../../types/store/query'; import {loadFromSessionStorage, saveToSessionStorage} from '../../../utils'; import {QUERY_EDITOR_CURRENT_QUERY_KEY, QUERY_EDITOR_DIRTY_KEY} from '../../../utils/constants'; import { addStreamingChunks as addStreamingChunksReducer, setStreamQueryResponse as setStreamQueryResponseReducer, setStreamSession as setStreamSessionReducer, } from './streaming/reducers'; import type {QueryResult, QueryState, QueryTabState} from './types'; import { applyQueryContentToTab, createDefaultTabState, getUniqueTabTitle, isQueryTabsDirtyPersistedState, isQueryTabsPersistedState, } from './utils'; import type { QueryTabPersistedState, QueryTabsDirtyPersistedState, QueryTabsPersistedState, } from './utils'; function persistTabsStateToSessionStorage(state: QueryState) { const persistedTabsById: Record = {}; for (const [tabId, tab] of Object.entries(state.tabsById)) { persistedTabsById[tabId] = { id: tabId, title: tab.title, isTitleUserDefined: Boolean(tab.isTitleUserDefined), isTouched: Boolean(tab.isTouched), input: tab.input, savedInput: tab.savedInput, savedQueryName: tab.savedQueryName, createdAt: tab.createdAt, updatedAt: tab.updatedAt, }; } const persistedState: QueryTabsPersistedState = { activeTabId: state.activeTabId, tabsOrder: state.tabsOrder, tabsById: persistedTabsById, newTabCounter: state.newTabCounter, }; saveToSessionStorage(QUERY_EDITOR_CURRENT_QUERY_KEY, persistedState); } function persistDirtyStateToSessionStorage(state: QueryState) { const persistedDirty: QueryTabsDirtyPersistedState = {}; for (const [tabId, tab] of Object.entries(state.tabsById)) { persistedDirty[tabId] = tab.isDirty; } saveToSessionStorage(QUERY_EDITOR_DIRTY_KEY, persistedDirty); } function createTabStateFromPersisted({ tabId, persistedTab, isDirty, }: { tabId: string; persistedTab: QueryTabPersistedState; isDirty: boolean; }): QueryTabState { return { id: tabId, title: persistedTab.title, isTitleUserDefined: persistedTab.isTitleUserDefined ?? false, isTouched: persistedTab.isTouched ?? false, input: persistedTab.input, savedInput: persistedTab.savedInput ?? (isDirty ? undefined : persistedTab.input), savedQueryName: persistedTab.savedQueryName, isDirty, createdAt: persistedTab.createdAt, updatedAt: persistedTab.updatedAt, }; } function createInitialTabsState(): Pick< QueryState, 'activeTabId' | 'tabsOrder' | 'tabsById' | 'newTabCounter' > { const rawTabs = loadFromSessionStorage(QUERY_EDITOR_CURRENT_QUERY_KEY); const rawDirty = loadFromSessionStorage(QUERY_EDITOR_DIRTY_KEY); // Backward-compatible migration: // Previously `QUERY_EDITOR_CURRENT_QUERY_KEY` stored a plain string query text. // New format expects `QueryTabsPersistedState`. If legacy string is detected, migrate to a // single-tab persisted state and overwrite sessionStorage in the new format. if (typeof rawTabs === 'string') { const tabId = uuidv4(); const legacyDirty = typeof rawDirty === 'boolean' ? rawDirty : false; const tab = createDefaultTabState({tabId, input: rawTabs}); tab.isDirty = legacyDirty; if (legacyDirty) { tab.savedInput = undefined; } const persistedState: QueryTabsPersistedState = { activeTabId: tabId, tabsOrder: [tabId], tabsById: { [tabId]: { id: tabId, title: tab.title, isTitleUserDefined: false, input: tab.input, createdAt: tab.createdAt, updatedAt: tab.updatedAt, }, }, }; saveToSessionStorage(QUERY_EDITOR_CURRENT_QUERY_KEY, persistedState); const persistedDirtyState: QueryTabsDirtyPersistedState = { [tabId]: legacyDirty, }; saveToSessionStorage(QUERY_EDITOR_DIRTY_KEY, persistedDirtyState); return { activeTabId: tabId, tabsOrder: [tabId], tabsById: { [tabId]: tab, }, newTabCounter: 1, }; } const persistedTabs = isQueryTabsPersistedState(rawTabs) ? rawTabs : undefined; const persistedDirty = isQueryTabsDirtyPersistedState(rawDirty) ? rawDirty : undefined; if (!persistedTabs) { const tabId = uuidv4(); return { activeTabId: tabId, tabsOrder: [tabId], tabsById: { [tabId]: createDefaultTabState({tabId}), }, newTabCounter: 1, }; } if ( Object.keys(persistedTabs.tabsById).length === 0 && persistedTabs.tabsOrder.length === 0 && persistedTabs.activeTabId === undefined ) { return { activeTabId: undefined, tabsOrder: [], tabsById: {}, newTabCounter: typeof persistedTabs.newTabCounter === 'number' ? persistedTabs.newTabCounter : 0, }; } const normalizedTabsOrder = persistedTabs.tabsOrder.filter((tabId) => { return Boolean(persistedTabs.tabsById[tabId]); }); const tabsOrder = normalizedTabsOrder.length > 0 ? normalizedTabsOrder : Object.keys(persistedTabs.tabsById); if (tabsOrder.length === 0) { const tabId = uuidv4(); return { activeTabId: tabId, tabsOrder: [tabId], tabsById: { [tabId]: createDefaultTabState({tabId}), }, newTabCounter: 1, }; } const tabsById: Record = {}; for (const tabId of tabsOrder) { const persistedTab = persistedTabs.tabsById[tabId]; if (!persistedTab) { continue; } tabsById[tabId] = createTabStateFromPersisted({ tabId, persistedTab, isDirty: persistedDirty?.[tabId] ?? false, }); } const activeTabId = persistedTabs.activeTabId && tabsById[persistedTabs.activeTabId] ? persistedTabs.activeTabId : tabsOrder[0]; const newTabCounter = typeof persistedTabs.newTabCounter === 'number' ? persistedTabs.newTabCounter : tabsOrder.length; return { activeTabId, tabsOrder, tabsById, newTabCounter, }; } function getActiveTab(state: QueryState): QueryTabState | undefined { return state.activeTabId ? state.tabsById[state.activeTabId] : undefined; } function getTabsBoundToSavedQuery( tabsById: QueryState['tabsById'], savedQueryName: string, ): QueryTabState[] { const tabs = Object.values(tabsById); const exactMatches = tabs.filter((tab) => tab.savedQueryName === savedQueryName); if (exactMatches.length) { return exactMatches; } const normalizedName = savedQueryName.trim().toLowerCase(); return tabs.filter((tab) => tab.savedQueryName?.trim().toLowerCase() === normalizedName); } function getSetQueryTabContentTitle({ tabsById, activeTab, title, ensureUniqueTitle, }: { tabsById: QueryState['tabsById']; activeTab: QueryTabState | undefined; title: string; ensureUniqueTitle?: boolean; }) { if (!ensureUniqueTitle) { return title; } if (activeTab && !activeTab.isTouched) { const tabsWithoutActiveTab = Object.fromEntries( Object.entries(tabsById).filter(([tabId]) => tabId !== activeTab.id), ); return getUniqueTabTitle(tabsWithoutActiveTab, title); } return getUniqueTabTitle(tabsById, title); } const initialTabsState = createInitialTabsState(); const initialState: QueryState = { ...initialTabsState, historyFilter: '', historyCurrentQueryId: undefined, }; const slice = createSlice({ name: 'query', initialState, reducers: { changeUserInput: (state, action: PayloadAction<{input: string}>) => { const tab = getActiveTab(state); if (!tab) { return; } tab.input = action.payload.input; // Template insertion goes through Monaco onChange, but should not make a tab touched. if (!tab.pendingSnippet) { tab.isTouched = true; } tab.updatedAt = Date.now(); if (tab.savedInput !== undefined) { tab.isDirty = tab.input !== tab.savedInput; persistDirtyStateToSessionStorage(state); } persistTabsStateToSessionStorage(state); }, setIsDirty: (state, action: PayloadAction) => { const tab = getActiveTab(state); if (!tab) { return; } tab.isDirty = action.payload; if (!action.payload) { tab.savedInput = tab.input; persistTabsStateToSessionStorage(state); } persistDirtyStateToSessionStorage(state); }, setQueryResult: ( state, action: PayloadAction<{ tabId: string; result: QueryResult | undefined; executionId?: string; }>, ) => { const tab = state.tabsById[action.payload.tabId]; if (!tab) { return; } if ( action.payload.executionId && tab.result?.executionId !== action.payload.executionId ) { return; } tab.result = action.payload.result; }, setLastExecutedQueryText: ( state, action: PayloadAction<{tabId: string; queryText: string}>, ) => { const tab = state.tabsById[action.payload.tabId]; if (!tab) { return; } tab.lastExecutedQueryText = action.payload.queryText; tab.isTouched = true; persistTabsStateToSessionStorage(state); }, addQueryTab: ( state, action: PayloadAction<{ tabId: string; title: string; input?: string; makeActive?: boolean; newTabCounter?: number; pendingSnippet?: string; savedQueryName?: string; }>, ) => { const { tabId, title, input = '', makeActive = true, newTabCounter, pendingSnippet, savedQueryName, } = action.payload; if (state.tabsById[tabId]) { if (makeActive) { state.activeTabId = tabId; persistTabsStateToSessionStorage(state); } return; } state.tabsById[tabId] = createDefaultTabState({ tabId, title, input, pendingSnippet, savedQueryName, }); state.tabsOrder.push(tabId); if (makeActive) { state.activeTabId = tabId; } if (newTabCounter !== undefined) { state.newTabCounter = newTabCounter; } persistTabsStateToSessionStorage(state); persistDirtyStateToSessionStorage(state); }, setQueryTabContent: ( state, action: PayloadAction<{ tabId: string; title: string; input?: string; pendingSnippet?: string; savedQueryName?: string; ensureUniqueTitle?: boolean; }>, ) => { const { tabId, title, input = '', pendingSnippet, savedQueryName, ensureUniqueTitle, } = action.payload; const activeTab = getActiveTab(state); const nextTitle = getSetQueryTabContentTitle({ tabsById: state.tabsById, activeTab, title, ensureUniqueTitle, }); if (activeTab && !activeTab.isTouched) { state.tabsById[activeTab.id] = applyQueryContentToTab({ tab: activeTab, title: nextTitle, input, pendingSnippet, savedQueryName, }); } else { state.tabsById[tabId] = createDefaultTabState({ tabId, title: nextTitle, input, pendingSnippet, savedQueryName, }); state.tabsOrder.push(tabId); state.activeTabId = tabId; } persistTabsStateToSessionStorage(state); persistDirtyStateToSessionStorage(state); }, applyExternalQueryToActiveTab: ( state, action: PayloadAction<{ title: string; input: string; pendingSnippet?: string; savedQueryName?: string; }>, ) => { const activeTab = getActiveTab(state); if (!activeTab) { const tabId = uuidv4(); state.tabsById[tabId] = createDefaultTabState({ tabId, title: action.payload.title, input: action.payload.input, pendingSnippet: action.payload.pendingSnippet, savedQueryName: action.payload.savedQueryName, }); state.tabsOrder.push(tabId); state.activeTabId = tabId; persistTabsStateToSessionStorage(state); persistDirtyStateToSessionStorage(state); return; } state.tabsById[activeTab.id] = applyQueryContentToTab({ tab: activeTab, title: action.payload.title, input: action.payload.input, pendingSnippet: action.payload.pendingSnippet, savedQueryName: action.payload.savedQueryName, }); persistTabsStateToSessionStorage(state); persistDirtyStateToSessionStorage(state); }, closeQueryTab: (state, action: PayloadAction<{tabId: string}>) => { const {tabId} = action.payload; if (!state.tabsById[tabId]) { return; } const isLastTab = state.tabsOrder.length <= 1; if (isLastTab) { state.activeTabId = undefined; state.tabsOrder = []; state.tabsById = {}; state.newTabCounter = 0; persistTabsStateToSessionStorage(state); persistDirtyStateToSessionStorage(state); return; } const index = state.tabsOrder.indexOf(tabId); state.tabsOrder = state.tabsOrder.filter((id) => id !== tabId); delete state.tabsById[tabId]; if (state.activeTabId === tabId) { const nextTabId = state.tabsOrder[index] ?? state.tabsOrder[index - 1] ?? state.tabsOrder[0]; state.activeTabId = nextTabId; } persistTabsStateToSessionStorage(state); persistDirtyStateToSessionStorage(state); }, renameQueryTab: (state, action: PayloadAction<{tabId: string; title: string}>) => { const {tabId, title} = action.payload; const tab = state.tabsById[tabId]; if (!tab) { return; } tab.title = title; tab.isTitleUserDefined = true; tab.isTouched = true; tab.updatedAt = Date.now(); persistTabsStateToSessionStorage(state); }, syncSavedQueryTab: ( state, action: PayloadAction<{tabId: string; title: string; savedQueryName: string}>, ) => { const {tabId, title, savedQueryName} = action.payload; const tab = state.tabsById[tabId]; if (!tab) { return; } tab.title = title; tab.savedQueryName = savedQueryName; tab.isTitleUserDefined = true; tab.isTouched = true; tab.updatedAt = Date.now(); persistTabsStateToSessionStorage(state); }, renameSavedQueryTabs: ( state, action: PayloadAction<{previousName: string; nextName: string}>, ) => { const matchingTabs = getTabsBoundToSavedQuery( state.tabsById, action.payload.previousName, ); matchingTabs.forEach((tab) => { tab.title = action.payload.nextName; tab.savedQueryName = action.payload.nextName; }); persistTabsStateToSessionStorage(state); }, syncSavedQueryTabsAfterUpdate: ( state, action: PayloadAction<{ sourceTabId: string; previousName: string; nextName: string; previousBody: string; nextBody: string; }>, ) => { const {sourceTabId, previousName, nextName, previousBody, nextBody} = action.payload; const matchingTabs = getTabsBoundToSavedQuery(state.tabsById, previousName); const isSavedQueryRenamed = previousName !== nextName; matchingTabs.forEach((tab) => { if (tab.id === sourceTabId || tab.input === previousBody) { tab.input = nextBody; } if (isSavedQueryRenamed) { tab.title = nextName; } tab.savedQueryName = nextName; tab.savedInput = nextBody; tab.isDirty = tab.input !== nextBody; }); if (matchingTabs.length) { persistTabsStateToSessionStorage(state); persistDirtyStateToSessionStorage(state); } }, detachSavedQueryTabs: (state, action: PayloadAction<{savedQueryName: string}>) => { const matchingTabs = getTabsBoundToSavedQuery( state.tabsById, action.payload.savedQueryName, ); matchingTabs.forEach((tab) => { tab.savedQueryName = undefined; tab.savedInput = undefined; tab.isDirty = true; }); if (matchingTabs.length) { persistTabsStateToSessionStorage(state); persistDirtyStateToSessionStorage(state); } }, setQueryTabSavedQueryName: ( state, action: PayloadAction<{tabId: string; savedQueryName: string | undefined}>, ) => { const tab = state.tabsById[action.payload.tabId]; if (!tab) { return; } tab.savedQueryName = action.payload.savedQueryName; persistTabsStateToSessionStorage(state); }, setActiveQueryTab: (state, action: PayloadAction<{tabId: string}>) => { const {tabId} = action.payload; if (!state.tabsById[tabId]) { return; } state.activeTabId = tabId; persistTabsStateToSessionStorage(state); }, clearPendingSnippet: (state, action: PayloadAction<{tabId: string}>) => { const tab = state.tabsById[action.payload.tabId]; if (tab) { delete tab.pendingSnippet; } }, setTenantPath: (state, action: PayloadAction) => { state.tenantPath = action.payload; }, setQueryHistoryFilter: (state, action: PayloadAction) => { state.historyFilter = action.payload; }, setHistoryCurrentQueryId: (state, action: PayloadAction) => { state.historyCurrentQueryId = action.payload; }, setResultTab: (state, action: PayloadAction<{queryType: QueryAction; tabId: string}>) => { const {queryType, tabId} = action.payload; if (!state.selectedResultTab) { state.selectedResultTab = {}; } state.selectedResultTab[queryType] = tabId; }, setStreamSession: setStreamSessionReducer, addStreamingChunks: addStreamingChunksReducer, setStreamQueryResponse: setStreamQueryResponseReducer, }, selectors: { selectActiveTabId: (state) => state.activeTabId, selectTabsOrder: (state) => state.tabsOrder, selectTabsById: (state) => state.tabsById, selectNewTabCounter: (state) => state.newTabCounter, selectActiveTab: (state) => state.activeTabId ? state.tabsById[state.activeTabId] : undefined, selectQueriesHistoryFilter: (state) => state.historyFilter || '', selectHistoryCurrentQueryId: (state) => state.historyCurrentQueryId, selectTenantPath: (state) => state.tenantPath, selectResult: (state) => state.activeTabId ? state.tabsById[state.activeTabId]?.result : undefined, selectStartTime: (state) => state.activeTabId ? state.tabsById[state.activeTabId]?.result?.startTime : undefined, selectEndTime: (state) => state.activeTabId ? state.tabsById[state.activeTabId]?.result?.endTime : undefined, selectUserInput: (state) => state.activeTabId ? state.tabsById[state.activeTabId]?.input || '' : '', selectIsDirty: (state) => state.activeTabId ? state.tabsById[state.activeTabId]?.isDirty || false : false, selectResultTab: (state) => state.selectedResultTab, selectLastExecutedQueryText: (state) => state.activeTabId ? state.tabsById[state.activeTabId]?.lastExecutedQueryText : undefined, selectActiveTabPendingSnippet: (state) => state.activeTabId ? state.tabsById[state.activeTabId]?.pendingSnippet : undefined, selectActiveTabSavedQueryName: (state) => state.activeTabId ? state.tabsById[state.activeTabId]?.savedQueryName : undefined, }, }); export const selectQueryDuration = createSelector( slice.selectors.selectStartTime, slice.selectors.selectEndTime, (startTime, endTime) => { return { startTime, endTime, }; }, ); export default slice.reducer; export const { changeUserInput, setQueryResult, setLastExecutedQueryText, addQueryTab, setQueryTabContent, applyExternalQueryToActiveTab, closeQueryTab, renameQueryTab, renameSavedQueryTabs, syncSavedQueryTabsAfterUpdate, detachSavedQueryTabs, syncSavedQueryTab, setQueryTabSavedQueryName, setActiveQueryTab, clearPendingSnippet, setTenantPath, setQueryHistoryFilter, setHistoryCurrentQueryId, addStreamingChunks, setStreamQueryResponse, setStreamSession, setIsDirty, setResultTab, } = slice.actions; export const { selectActiveTabId, selectTabsOrder, selectTabsById, selectActiveTab, selectQueriesHistoryFilter, selectHistoryCurrentQueryId, selectTenantPath, selectResult, selectUserInput, selectIsDirty, selectResultTab, selectLastExecutedQueryText, selectNewTabCounter, selectActiveTabPendingSnippet, selectActiveTabSavedQueryName, } = slice.selectors;