diff --git a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js index 78f2a26505c..97cf539a34e 100644 --- a/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js +++ b/packages/bruno-app/src/providers/App/ConfirmAppClose/SaveRequestsModal.js @@ -4,7 +4,7 @@ import filter from 'lodash/filter'; import groupBy from 'lodash/groupBy'; import { useSelector } from 'react-redux'; import { useDispatch } from 'react-redux'; -import { findCollectionByUid, flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections'; +import { flattenItems, isItemARequest, hasRequestChanges, findEnvironmentInCollection } from 'utils/collections'; import { pluralizeWord } from 'utils/common'; import { getInvalidVariableNames } from 'utils/common/variables'; import { isEnvironmentValidationError } from 'utils/environments'; @@ -16,6 +16,8 @@ import { IconAlertTriangle } from '@tabler/icons'; import Modal from 'components/Modal'; import Button from 'ui/Button'; import toast from 'react-hot-toast'; +import { useStore } from 'react-redux'; +import { clearPersistedDraftSession, persistDraftSession } from 'providers/ReduxStore/utils/draftSession'; const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [] }) => { const MAX_UNSAVED_ITEMS_TO_SHOW = 5; @@ -24,6 +26,7 @@ const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [ const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments); const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft); const dispatch = useDispatch(); + const store = useStore(); const allDrafts = useMemo(() => { const requestDrafts = []; @@ -34,8 +37,8 @@ const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [ const relevantTabs = forceCloseTabs ? tabs.filter((t) => tabUidsToClose.includes(t.uid)) : tabs; const tabsByCollection = groupBy(relevantTabs, (t) => t.collectionUid); - Object.keys(tabsByCollection).forEach((collectionUid) => { - const collection = findCollectionByUid(collections, collectionUid); + collections.forEach((collection) => { + const collectionUid = collection?.uid; if (collection) { // Check for collection draft if (collection.draft) { @@ -108,8 +111,8 @@ const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [ } } - return [...collectionDrafts, ...folderDrafts, ...environmentDrafts, ...appDrafts, ...requestDrafts]; - }, [collections, tabs, globalEnvironments, globalEnvironmentDraft, forceCloseTabs, tabUidsToClose]); + return [...collectionDrafts, ...folderDrafts, ...environmentDrafts, ...requestDrafts]; + }, [collections, globalEnvironments, globalEnvironmentDraft]); const totalDraftsCount = allDrafts.length; @@ -121,6 +124,8 @@ const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [ } else { dispatch(completeQuitFlow()); } + clearPersistedDraftSession(); + return dispatch(completeQuitFlow()); } }, [totalDraftsCount, dispatch, forceCloseTabs, tabUidsToClose]); @@ -151,6 +156,8 @@ const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [ } else { dispatch(completeQuitFlow()); } + persistDraftSession(store.getState()); + dispatch(completeQuitFlow()); onClose(); }; @@ -229,6 +236,8 @@ const SaveRequestsModal = ({ onClose, forceCloseTabs = false, tabUidsToClose = [ } else { dispatch(completeQuitFlow()); } + clearPersistedDraftSession(); + dispatch(completeQuitFlow()); onClose(); } catch (error) { console.error('Error saving drafts:', error); diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 81a5830c91d..2305dcf1d16 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -45,7 +45,7 @@ import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'provide import toast from 'react-hot-toast'; import { useDispatch, useStore } from 'react-redux'; import { isElectron } from 'utils/common/platform'; -import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments, _clearScriptGlobalEnvBaseline } from 'providers/ReduxStore/slices/global-environments'; +import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments, _clearScriptGlobalEnvBaseline, restoreGlobalEnvironmentDraftFromSession } from 'providers/ReduxStore/slices/global-environments'; import { collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState, collectionLoadedFromTree } from 'providers/ReduxStore/slices/collections/index'; import { migrationProgressEvent } from 'providers/ReduxStore/slices/collection-migration'; import { addLog } from 'providers/ReduxStore/slices/logs'; @@ -174,6 +174,7 @@ const useIpcEvents = () => { workspacePath: workspace.pathname }).then((result) => { dispatch(updateGlobalEnvironments(result)); + dispatch(restoreGlobalEnvironmentDraftFromSession()); }).catch((error) => { console.error('Error refreshing global environments:', error); }); @@ -192,6 +193,7 @@ const useIpcEvents = () => { workspacePath: workspace.pathname }).then((result) => { dispatch(updateGlobalEnvironments(result)); + dispatch(restoreGlobalEnvironmentDraftFromSession()); }).catch((error) => { console.error('Error refreshing global environments:', error); }); @@ -210,6 +212,7 @@ const useIpcEvents = () => { workspacePath: workspace.pathname }).then((result) => { dispatch(updateGlobalEnvironments(result)); + dispatch(restoreGlobalEnvironmentDraftFromSession()); }).catch((error) => { console.error('Error refreshing global environments:', error); }); @@ -346,6 +349,7 @@ const useIpcEvents = () => { const removeGlobalEnvironmentsUpdatesListener = ipcRenderer.on('main:load-global-environments', (val) => { dispatch(updateGlobalEnvironments(val)); + dispatch(restoreGlobalEnvironmentDraftFromSession()); }); const removeSnapshotHydrationListener = ipcRenderer.on('main:hydrate-app-with-ui-state-snapshot', (val) => { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index 69cad0c2565..72cf3708e7d 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -117,6 +117,7 @@ import { hydrateCollectionTabs, hydrateSnapshotLookups } from 'utils/snapshot'; +import { getPersistedCollectionDraftSession } from 'providers/ReduxStore/utils/draftSession'; // Display name for a cloned/pasted item: always " copy" (semantic). // Filename uniqueness is resolved silently by the electron main process @@ -2892,12 +2893,19 @@ export const openScratchCollectionEvent = (uid, pathname, brunoConfig) => (dispa brunoConfig }; + const persistedDraftSession = getPersistedCollectionDraftSession(pathname); + ipcRenderer .invoke('renderer:get-collection-security-config', pathname) .then((securityConfig) => { collectionSchema .validate(collection) - .then(() => dispatch(_createCollection({ ...collection, securityConfig }))) + .then(() => dispatch(_createCollection({ + ...collection, + securityConfig, + draft: persistedDraftSession?.collectionDraft || null, + persistedDraftSession + }))) .then(resolve) .catch(reject); }) @@ -2979,10 +2987,17 @@ export const openCollectionEvent = (uid, pathname, brunoConfig, options = {}) => brunoConfig: brunoConfig }; + const persistedDraftSession = getPersistedCollectionDraftSession(pathname); + ipcRenderer.invoke('renderer:get-collection-security-config', pathname).then((securityConfig) => { collectionSchema .validate(collection) - .then(() => dispatch(_createCollection({ ...collection, securityConfig }))) + .then(() => dispatch(_createCollection({ + ...collection, + securityConfig, + draft: persistedDraftSession?.collectionDraft || null, + persistedDraftSession + }))) .then(() => { const currentState = getState(); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 8289658fe9c..04ee3dfa108 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -21,7 +21,7 @@ import { applyScriptEnvVars, getScriptModifiedKeys } from 'utils/environments'; import { getSubdirectoriesFromRoot } from 'utils/common/platform'; import toast from 'react-hot-toast'; import mime from 'mime-types'; -import path from 'utils/common/path'; +import path, { normalizePath } from 'utils/common/path'; import { getUniqueTagsFromItems } from 'utils/collections/index'; import { DEFAULT_HTTP_ITEM_SETTINGS, GRPC_SCRIPT_KEYS, SCRIPT_TYPES } from '@usebruno/common'; import * as exampleReducers from './exampleReducers'; @@ -194,6 +194,57 @@ const mergeRequestWithPreservedUids = (existingRequest, newRequest) => const mergeRootWithPreservedUids = (existingRoot, newRoot) => preserveUidsAtPaths(existingRoot, newRoot, ROOT_UID_PATHS); +const consumePersistedItemDraft = (collection, draftType, pathname) => { + if (!collection?.persistedDraftSession || !pathname) { + return null; + } + + const normalizedPath = normalizePath(pathname); + const draft = collection.persistedDraftSession?.[draftType]?.[normalizedPath]; + + if (draft) { + delete collection.persistedDraftSession[draftType][normalizedPath]; + } + + return draft || null; +}; + +const applyPersistedFolderDraft = (collection, folderItem, pathname) => { + if (!folderItem?.draft) { + const draft = consumePersistedItemDraft(collection, 'folderDrafts', pathname || folderItem?.pathname); + if (draft) { + folderItem.draft = draft; + } + } +}; + +const applyPersistedRequestDraft = (collection, requestItem, pathname) => { + if (!requestItem?.draft) { + const draft = consumePersistedItemDraft(collection, 'requestDrafts', pathname || requestItem?.pathname); + if (draft) { + requestItem.draft = draft; + } + } +}; + +const applyPersistedEnvironmentDraft = (collection, environment) => { + const persistedDraft = collection?.persistedDraftSession?.environmentsDraft; + if (collection?.environmentsDraft || !persistedDraft || !environment) { + return; + } + + const isMatch = (persistedDraft.environmentUid && persistedDraft.environmentUid === environment.uid) + || (persistedDraft.environmentName && persistedDraft.environmentName === environment.name); + + if (isMatch) { + collection.environmentsDraft = { + environmentUid: environment.uid, + variables: persistedDraft.variables + }; + delete collection.persistedDraftSession.environmentsDraft; + } +}; + const initialState = { collections: [], collectionSortOrder: 'default', @@ -3063,6 +3114,7 @@ export const collectionsSlice = createSlice({ if (file?.data?.meta?.seq) { folderItem.seq = file.data?.meta?.seq; } + applyPersistedFolderDraft(collection, folderItem, folderPath); } return; } @@ -3094,6 +3146,7 @@ export const collectionsSlice = createSlice({ // Update existing folder to be transient if the file is transient childItem.isTransient = true; } + applyPersistedFolderDraft(collection, childItem, currentPath); currentSubItems = childItem.items; } @@ -3119,8 +3172,9 @@ export const collectionsSlice = createSlice({ currentItem.size = file.size; currentItem.error = file.error; currentItem.isTransient = isTransientFile; + applyPersistedRequestDraft(collection, currentItem, file.meta.pathname); } else { - currentSubItems.push({ + const newItem = { uid: file.data.uid, name: file.data.name, type: file.data.type, @@ -3139,7 +3193,9 @@ export const collectionsSlice = createSlice({ size: file.size, error: file.error, isTransient: isTransientFile - }); + }; + applyPersistedRequestDraft(collection, newItem, file.meta.pathname); + currentSubItems.push(newItem); } } } @@ -3191,6 +3247,7 @@ export const collectionsSlice = createSlice({ // Update existing folder to be transient if the directory is transient childItem.isTransient = true; } + applyPersistedFolderDraft(collection, childItem, currentPath); currentSubItems = childItem.items; }); } @@ -3218,6 +3275,7 @@ export const collectionsSlice = createSlice({ folderItem.seq = file?.data?.meta?.seq; } folderItem.root = mergeRootWithPreservedUids(folderItem.root, file.data); + applyPersistedFolderDraft(collection, folderItem, folderPath); } return; } @@ -3281,6 +3339,8 @@ export const collectionsSlice = createSlice({ item.draft = null; } } + + applyPersistedRequestDraft(collection, item, file.meta.pathname); } } }, @@ -3324,9 +3384,24 @@ export const collectionsSlice = createSlice({ existingEnv.color = environment.color; existingEnv.externalSecrets = environment.externalSecrets; existingEnv.extends = environment.extends; + /* + Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves. + */ + prevEphemerals.forEach((ev) => { + const target = existingEnv.variables?.find((v) => v.name === ev.name); + if (target) { + if (target.value !== ev.value) { + if (target.persistedValue === undefined) target.persistedValue = target.value; + target.value = ev.value; + } + target.ephemeral = true; + } + }); + applyPersistedEnvironmentDraft(collection, existingEnv); } else { collection.environments.push(environment); collection.environments.sort((a, b) => a.name.localeCompare(b.name)); + applyPersistedEnvironmentDraft(collection, environment); const lastAction = collection.lastAction; if (lastAction && lastAction.type === 'ADD_ENVIRONMENT') { diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js index 58f93d062a8..7d7e11fa7e1 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/global-environments.js @@ -5,6 +5,7 @@ import { getDataTypeFromValue, parseValueByDataType, resolveEnvironmentInheritan import { cloneDeep, isEqual } from 'lodash'; import { applyScriptEnvVars, getScriptModifiedKeys, writesCollidingSecrets, DUPLICATE_SECRET_NAMES_ERROR } from 'utils/environments'; import { getInvalidVariableNames, invalidVariableNamesError } from 'utils/common/variables'; +import { getPersistedDraftSession } from 'providers/ReduxStore/utils/draftSession'; const initialState = { globalEnvironments: [], @@ -418,4 +419,29 @@ export const updateGlobalEnvironmentColor = (environmentUid, color) => (dispatch }); }; +export const restoreGlobalEnvironmentDraftFromSession = () => (dispatch, getState) => { + const session = getPersistedDraftSession(); + const persistedDraft = session?.globalEnvironmentDraft; + + if (!persistedDraft) { + return; + } + + const state = getState(); + const globalEnvironments = state.globalEnvironments?.globalEnvironments || []; + const environment = globalEnvironments.find((env) => + env.uid === persistedDraft.environmentUid + || (persistedDraft.environmentName && env.name === persistedDraft.environmentName) + ); + + if (!environment) { + return; + } + + dispatch(setGlobalEnvironmentDraft({ + environmentUid: environment.uid, + variables: persistedDraft.variables + })); +}; + export default globalEnvironmentsSlice.reducer; diff --git a/packages/bruno-app/src/providers/ReduxStore/utils/draftSession.js b/packages/bruno-app/src/providers/ReduxStore/utils/draftSession.js new file mode 100644 index 00000000000..8c9c89aef72 --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/utils/draftSession.js @@ -0,0 +1,157 @@ +import { flattenItems, findEnvironmentInCollection, hasRequestChanges, isItemAFolder, isItemARequest } from 'utils/collections'; +import { normalizePath } from 'utils/common/path'; + +const STORAGE_KEY = 'bruno.appCloseDraftSession.v1'; + +const isStorageAvailable = () => { + try { + return typeof window !== 'undefined' && !!window.localStorage; + } catch (error) { + return false; + } +}; + +const safeParse = (value) => { + if (!value) { + return null; + } + + try { + return JSON.parse(value); + } catch (error) { + return null; + } +}; + +const getCollectionEnvironmentDraft = (collection) => { + if (!collection?.environmentsDraft?.environmentUid || !collection?.environmentsDraft?.variables) { + return null; + } + + const environment = findEnvironmentInCollection(collection, collection.environmentsDraft.environmentUid); + + return { + environmentUid: collection.environmentsDraft.environmentUid, + environmentName: environment?.name || null, + variables: collection.environmentsDraft.variables + }; +}; + +const getGlobalEnvironmentDraft = (state) => { + const draft = state?.globalEnvironments?.globalEnvironmentDraft; + if (!draft?.environmentUid || !draft?.variables) { + return null; + } + + const environment = state?.globalEnvironments?.globalEnvironments?.find((env) => env.uid === draft.environmentUid); + + return { + environmentUid: draft.environmentUid, + environmentName: environment?.name || null, + variables: draft.variables + }; +}; + +const getCollectionDraftSession = (collection) => { + if (!collection?.pathname) { + return null; + } + + const requestDrafts = {}; + const folderDrafts = {}; + const items = flattenItems(collection.items || []); + + items.forEach((item) => { + if (!item?.draft || !item?.pathname) { + return; + } + + const normalizedPath = normalizePath(item.pathname); + + if (isItemARequest(item) && hasRequestChanges(item)) { + requestDrafts[normalizedPath] = item.draft; + return; + } + + if (isItemAFolder(item)) { + folderDrafts[normalizedPath] = item.draft; + } + }); + + const environmentsDraft = getCollectionEnvironmentDraft(collection); + const hasRequestDrafts = Object.keys(requestDrafts).length > 0; + const hasFolderDrafts = Object.keys(folderDrafts).length > 0; + const hasCollectionDraft = !!collection.draft; + const hasEnvironmentDraft = !!environmentsDraft; + + if (!hasRequestDrafts && !hasFolderDrafts && !hasCollectionDraft && !hasEnvironmentDraft) { + return null; + } + + return { + pathname: normalizePath(collection.pathname), + collectionDraft: collection.draft || null, + requestDrafts, + folderDrafts, + environmentsDraft + }; +}; + +export const getPersistedDraftSession = () => { + if (!isStorageAvailable()) { + return null; + } + + try { + return safeParse(window.localStorage.getItem(STORAGE_KEY)); + } catch (error) { + return null; + } +}; + +export const getPersistedCollectionDraftSession = (pathname) => { + if (!pathname) { + return null; + } + + const session = getPersistedDraftSession(); + return session?.collections?.[normalizePath(pathname)] || null; +}; + +export const persistDraftSession = (state) => { + if (!isStorageAvailable()) { + return; + } + + const collections = state?.collections?.collections || []; + const snapshot = { + version: 1, + collections: {}, + globalEnvironmentDraft: getGlobalEnvironmentDraft(state) + }; + + collections.forEach((collection) => { + const collectionDraftSession = getCollectionDraftSession(collection); + if (collectionDraftSession) { + snapshot.collections[collectionDraftSession.pathname] = collectionDraftSession; + } + }); + + const hasCollectionDrafts = Object.keys(snapshot.collections).length > 0; + const hasGlobalDraft = !!snapshot.globalEnvironmentDraft; + + if (!hasCollectionDrafts && !hasGlobalDraft) { + window.localStorage.removeItem(STORAGE_KEY); + return; + } + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); +}; + +export const clearPersistedDraftSession = () => { + if (!isStorageAvailable()) { + return; + } + + window.localStorage.removeItem(STORAGE_KEY); +};