diff --git a/.github/workflows/flaky-test-detector.yml b/.github/workflows/flaky-test-detector.yml index de2120fc7..f78b3fc44 100644 --- a/.github/workflows/flaky-test-detector.yml +++ b/.github/workflows/flaky-test-detector.yml @@ -9,6 +9,7 @@ on: permissions: contents: read pull-requests: write + issues: write checks: write jobs: diff --git a/packages/bruno-app/src/components/AppTitleBar/index.js b/packages/bruno-app/src/components/AppTitleBar/index.js index d9e7134ac..a09393b37 100644 --- a/packages/bruno-app/src/components/AppTitleBar/index.js +++ b/packages/bruno-app/src/components/AppTitleBar/index.js @@ -4,10 +4,11 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'; import toast from 'react-hot-toast'; import { useDispatch, useSelector } from 'react-redux'; -import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; +import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs'; import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions'; import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces'; +import { focusTab } from 'providers/ReduxStore/slices/tabs'; import Bruno from 'components/Bruno'; import MenuDropdown from 'ui/MenuDropdown'; @@ -129,7 +130,10 @@ const AppTitleBar = () => { }); const handleHomeClick = () => { - dispatch(showHomePage()); + const scratchCollectionUid = activeWorkspace?.scratchCollectionUid; + if (scratchCollectionUid) { + dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` })); + } }; const handleWorkspaceSwitch = (workspaceUid) => { diff --git a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js index 723d5a750..33d379db8 100644 --- a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js +++ b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js @@ -85,6 +85,17 @@ const Wrapper = styled.div` justify-content: center; } + .dropdown-tab-count { + margin-left: auto; + font-size: 11px; + font-weight: 500; + padding: 1px 6px; + border-radius: 10px; + background: ${(props) => props.theme.dropdown.hoverBg}; + min-width: 18px; + text-align: center; + } + &:hover:not(:disabled):not(.disabled) { background-color: ${(props) => props.theme.dropdown.hoverBg}; } diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index ad53e7af2..296670116 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -32,7 +32,7 @@ import WSRequestPane from 'components/RequestPane/WSRequestPane'; import WSResponsePane from 'components/ResponsePane/WsResponsePane'; import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index'; import ResponseExample from 'components/ResponseExample'; -import WorkspaceHome from 'components/WorkspaceHome'; +import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview'; import Preferences from 'components/Preferences'; import EnvironmentSettings from 'components/Environments/EnvironmentSettings'; import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings'; @@ -43,9 +43,6 @@ const MIN_TOP_PANE_HEIGHT = 150; const MIN_BOTTOM_PANE_HEIGHT = 150; const RequestTabPanel = () => { - if (typeof window == 'undefined') { - return
; - } const dispatch = useDispatch(); const tabs = useSelector((state) => state.tabs.tabs); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); @@ -53,6 +50,8 @@ const RequestTabPanel = () => { const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments); const _collections = useSelector((state) => state.collections.collections); const preferences = useSelector((state) => state.app.preferences); + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical'; const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen); @@ -171,6 +170,10 @@ const RequestTabPanel = () => { } }, [isConsoleOpen, isVerticalLayout]); + if (typeof window == 'undefined') { + return
; + } + if (!activeTabUid || !focusedTab) { return
An error occurred!
; } @@ -183,6 +186,14 @@ const RequestTabPanel = () => { return ; } + if (focusedTab.type === 'workspaceOverview') { + return activeWorkspace ? : null; + } + + if (focusedTab.type === 'workspaceEnvironments') { + return ; + } + if (!focusedTab.uid || !focusedTab.collectionUid) { return
An error occurred!
; } diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js new file mode 100644 index 000000000..31eb50f34 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js @@ -0,0 +1,125 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + .collection-switcher { + display: flex; + align-items: center; + gap: 4px; + } + + .switcher-trigger { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border: none; + border-radius: 4px; + background: transparent; + color: ${(props) => props.theme.text}; + cursor: pointer; + font-size: 15px; + font-weight: 600; + transition: background-color 0.15s ease; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + + .switcher-name { + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .tab-count { + font-size: 11px; + font-weight: 500; + padding: 1px 6px; + border-radius: 10px; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + min-width: 18px; + text-align: center; + } + + .chevron { + opacity: 0.6; + flex-shrink: 0; + } + } + + .workspace-actions-trigger { + cursor: pointer; + opacity: 0.6; + padding: 4px; + border-radius: 4px; + transition: opacity 0.15s ease, background-color 0.15s ease; + + &:hover { + opacity: 1; + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + } + + .workspace-rename-container { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + } + + .workspace-name-input { + font-size: 14px; + font-weight: 500; + padding: 2px 6px; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: 3px; + background: ${(props) => props.theme.input.bg}; + color: ${(props) => props.theme.text}; + outline: none; + min-width: 150px; + + &:focus { + border-color: ${(props) => props.theme.input.focusBorder}; + } + } + + .inline-actions { + display: flex; + align-items: center; + gap: 2px; + } + + .inline-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: none; + border-radius: 3px; + cursor: pointer; + background: transparent; + color: ${(props) => props.theme.text}; + + &:hover { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + } + + &.save { + color: ${(props) => props.theme.colors.text.green}; + } + + &.cancel { + color: ${(props) => props.theme.colors.text.danger}; + } + } + + .workspace-error { + font-size: 12px; + color: ${(props) => props.theme.colors.text.danger}; + margin-left: 8px; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js new file mode 100644 index 000000000..a377e2d97 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -0,0 +1,452 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + IconCategory, + IconBox, + IconChevronDown, + IconRun, + IconEye, + IconSettings, + IconDots, + IconEdit, + IconX, + IconCheck, + IconFolder, + IconUpload +} from '@tabler/icons'; +import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions'; +import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; +import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs'; +import { uuid } from 'utils/common'; +import toast from 'react-hot-toast'; +import Dropdown from 'components/Dropdown'; +import CloseWorkspace from 'components/Sidebar/CloseWorkspace'; +import EnvironmentSelector from 'components/Environments/EnvironmentSelector'; +import ToolHint from 'components/ToolHint'; +import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode'; +import ActionIcon from 'ui/ActionIcon'; +import { getRevealInFolderLabel } from 'utils/common/platform'; +import classNames from 'classnames'; +import StyledWrapper from './StyledWrapper'; + +const CollectionHeader = ({ collection, isScratchCollection }) => { + const dispatch = useDispatch(); + const workspaces = useSelector((state) => state.workspaces.workspaces); + const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + const collections = useSelector((state) => state.collections.collections); + const tabs = useSelector((state) => state.tabs.tabs); + + // Get the current active workspace + const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + + // Workspace rename state + const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false); + const [workspaceNameInput, setWorkspaceNameInput] = useState(''); + const [workspaceNameError, setWorkspaceNameError] = useState(''); + const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false); + + const switcherRef = useRef(); + const workspaceActionsRef = useRef(); + const workspaceNameInputRef = useRef(null); + const workspaceRenameContainerRef = useRef(null); + + const onSwitcherCreate = (ref) => (switcherRef.current = ref); + const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref); + + const handleCancelWorkspaceRename = useCallback(() => { + setIsRenamingWorkspace(false); + setWorkspaceNameInput(''); + setWorkspaceNameError(''); + }, []); + + useEffect(() => { + if (!isRenamingWorkspace) return; + + const handleClickOutside = (event) => { + if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) { + handleCancelWorkspaceRename(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isRenamingWorkspace, handleCancelWorkspaceRename]); + + if (!collection) { + return null; + } + + // Get mounted collections for the current workspace (excluding scratch collections) + const mountedCollections = collections.filter((c) => { + if (c.mountStatus !== 'mounted') return false; + + const isScratch = workspaces.some((w) => w.scratchCollectionUid === c.uid); + if (isScratch) return false; + + const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || []; + return workspaceCollectionPaths.some((wcPath) => c.pathname === wcPath); + }); + + // Count tabs for the current collection + const tabCount = tabs.filter((t) => t.collectionUid === collection.uid).length; + + // Get tab count for a given collection uid + const getTabCount = (collectionUid) => tabs.filter((t) => t.collectionUid === collectionUid).length; + + // Get tab count for workspace (scratch collection) + const workspaceTabCount = currentWorkspace?.scratchCollectionUid + ? getTabCount(currentWorkspace.scratchCollectionUid) + : 0; + + // Display name and icon based on context + const displayName = isScratchCollection + ? (currentWorkspace?.name || 'Untitled Workspace') + : (collection.name || 'Untitled Collection'); + + const DisplayIcon = isScratchCollection ? IconCategory : IconBox; + + // Switcher handlers + const handleSwitchToWorkspace = (workspaceUid) => { + switcherRef.current?.hide(); + if (workspaceUid) { + dispatch(switchWorkspace(workspaceUid)); + } + }; + + const handleSwitchToCollection = (targetCollection) => { + switcherRef.current?.hide(); + if (!targetCollection?.uid) return; + + const existingTab = tabs.find((t) => t.collectionUid === targetCollection.uid); + if (existingTab) { + dispatch(focusTab({ uid: existingTab.uid })); + } else { + dispatch( + addTab({ + uid: targetCollection.uid, + collectionUid: targetCollection.uid, + type: 'collection-settings' + }) + ); + } + }; + + // Collection action handlers + const handleRun = () => { + dispatch( + addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'collection-runner' + }) + ); + }; + + const viewVariables = () => { + dispatch( + addTab({ + uid: uuid(), + collectionUid: collection.uid, + type: 'variables' + }) + ); + }; + + const viewCollectionSettings = () => { + dispatch( + addTab({ + uid: collection.uid, + collectionUid: collection.uid, + type: 'collection-settings' + }) + ); + }; + + // Workspace action handlers (only used when isScratchCollection is true) + const handleRenameWorkspaceClick = () => { + workspaceActionsRef.current?.hide(); + setIsRenamingWorkspace(true); + setWorkspaceNameInput(currentWorkspace?.name || ''); + setWorkspaceNameError(''); + setTimeout(() => { + workspaceNameInputRef.current?.focus(); + workspaceNameInputRef.current?.select(); + }, 50); + }; + + const handleCloseWorkspaceClick = () => { + workspaceActionsRef.current?.hide(); + if (currentWorkspace?.type === 'default') { + toast.error('Cannot close the default workspace'); + return; + } + setCloseWorkspaceModalOpen(true); + }; + + const handleShowInFolder = () => { + workspaceActionsRef.current?.hide(); + const pathname = currentWorkspace?.pathname; + if (pathname) { + dispatch(showInFolder(pathname)).catch(() => { + toast.error('Error opening the folder'); + }); + } + }; + + const handleExportWorkspace = () => { + workspaceActionsRef.current?.hide(); + const uid = currentWorkspace?.uid; + if (!uid) return; + + dispatch(exportWorkspaceAction(uid)) + .then((result) => { + if (!result?.canceled) { + toast.success('Workspace exported successfully'); + } + }) + .catch((error) => { + toast.error(error?.message || 'Error exporting workspace'); + }); + }; + + const validateWorkspaceName = (name) => { + const trimmed = name?.trim(); + if (!trimmed) { + return 'Name is required'; + } + if (trimmed.length > 255) { + return 'Must be 255 characters or less'; + } + return null; + }; + + const handleSaveWorkspaceRename = () => { + const error = validateWorkspaceName(workspaceNameInput); + if (error) { + setWorkspaceNameError(error); + return; + } + + const uid = currentWorkspace?.uid; + if (!uid) return; + + dispatch(renameWorkspaceAction(uid, workspaceNameInput)) + .then(() => { + toast.success('Workspace renamed!'); + setIsRenamingWorkspace(false); + setWorkspaceNameInput(''); + setWorkspaceNameError(''); + }) + .catch((err) => { + toast.error(err?.message || 'An error occurred while renaming the workspace'); + setWorkspaceNameError(err?.message || 'Failed to rename workspace'); + }); + }; + + const handleWorkspaceNameChange = (e) => { + setWorkspaceNameInput(e.target.value); + if (workspaceNameError) { + setWorkspaceNameError(''); + } + }; + + const handleWorkspaceNameKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveWorkspaceRename(); + } else if (e.key === 'Escape') { + e.preventDefault(); + handleCancelWorkspaceRename(); + } + }; + + // Check if workspace actions should be shown + const showWorkspaceActions = isScratchCollection + && currentWorkspace + && currentWorkspace.type !== 'default' + && !isRenamingWorkspace; + + return ( + + {closeWorkspaceModalOpen && currentWorkspace?.uid && ( + setCloseWorkspaceModalOpen(false)} + /> + )} + +
+ {/* Left side: Switcher dropdown or rename input */} +
+ {isRenamingWorkspace ? ( +
+ + +
+ + +
+ {workspaceNameError && ( + {workspaceNameError} + )} +
+ ) : ( + document.body} + icon={( + + )} + > + {/* Workspace section */} + {currentWorkspace && ( + <> +
Workspace
+
handleSwitchToWorkspace(currentWorkspace.uid)} + > +
+ +
+ + {currentWorkspace.name || 'Untitled Workspace'} + + {workspaceTabCount > 0 && ( + {workspaceTabCount} + )} +
+ + )} + + {/* Collections section */} + {mountedCollections.length > 0 && ( + <> +
+
Collections
+ {mountedCollections.map((col) => { + const colTabCount = getTabCount(col.uid); + return ( +
handleSwitchToCollection(col)} + > +
+ +
+ {col.name || 'Untitled Collection'} + {colTabCount > 0 && ( + {colTabCount} + )} +
+ ); + })} + + )} + + )} + + {/* Workspace actions dropdown */} + {showWorkspaceActions && ( + document.body} + icon={} + > +
+
+ +
+ Rename +
+
+
+ +
+ {getRevealInFolderLabel()} +
+
+
+ +
+ Export +
+
+
+ +
+ Close +
+
+ )} +
+ + {/* Right side: Actions (only for regular collections) */} + {!isScratchCollection && ( +
+ + + + + + + + + + + + + + + + + + + +
+ )} +
+ + ); +}; + +export default CollectionHeader; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js deleted file mode 100644 index ec278887d..000000000 --- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js +++ /dev/null @@ -1,5 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div``; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js deleted file mode 100644 index 3c53cdbef..000000000 --- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { uuid } from 'utils/common'; -import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons'; -import EnvironmentSelector from 'components/Environments/EnvironmentSelector'; -import { addTab } from 'providers/ReduxStore/slices/tabs'; -import { useDispatch } from 'react-redux'; -import ToolHint from 'components/ToolHint'; -import StyledWrapper from './StyledWrapper'; -import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode'; -import ActionIcon from 'ui/ActionIcon'; - -const CollectionToolBar = ({ collection }) => { - const dispatch = useDispatch(); - - if (!collection) { - return null; - } - - const handleRun = () => { - dispatch( - addTab({ - uid: uuid(), - collectionUid: collection.uid, - type: 'collection-runner' - }) - ); - }; - - const viewVariables = () => { - dispatch( - addTab({ - uid: uuid(), - collectionUid: collection.uid, - type: 'variables' - }) - ); - }; - - const viewCollectionSettings = () => { - dispatch( - addTab({ - uid: collection.uid, - collectionUid: collection.uid, - type: 'collection-settings' - }) - ); - }; - - return ( - -
- -
- - - - - - - - - - - - - - - - {/* ToolHint is present within the JsSandboxMode component */} - - - - -
-
-
- ); -}; - -export default CollectionToolBar; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js index dad87bf1b..5905d6fbf 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js @@ -1,6 +1,6 @@ import React from 'react'; import GradientCloseButton from './GradientCloseButton'; -import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld } from '@tabler/icons'; +import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld, IconHome } from '@tabler/icons'; const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => { const getTabInfo = (type, tabName) => { @@ -69,6 +69,22 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra ); } + case 'workspaceOverview': { + return ( + <> + + Overview + + ); + } + case 'workspaceEnvironments': { + return ( + <> + + Environments + + ); + } } }; @@ -80,7 +96,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra > {getTabInfo(type, tabName)}
- handleCloseClick(e)} /> + {handleCloseClick && handleCloseClick(e)} />} ); }; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index 759eb88b2..d6a4746cd 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -172,7 +172,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi setShowConfirmGlobalEnvironmentClose(true); }; - if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences'].includes(tab.type)) { + if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences', 'workspaceOverview', 'workspaceEnvironments'].includes(tab.type)) { return ( dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} /> ) : tab.type === 'global-environment-settings' ? ( dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} /> + ) : tab.type === 'workspaceOverview' ? ( + + ) : tab.type === 'workspaceEnvironments' ? ( + ) : ( dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} /> )} diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js index b10552008..0c67fe517 100644 --- a/packages/bruno-app/src/components/RequestTabs/index.js +++ b/packages/bruno-app/src/components/RequestTabs/index.js @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; +import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import find from 'lodash/find'; import filter from 'lodash/filter'; import classnames from 'classnames'; @@ -6,7 +6,7 @@ import { IconChevronRight, IconChevronLeft } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs'; import NewRequest from 'components/Sidebar/NewRequest'; -import CollectionToolBar from './CollectionToolBar'; +import CollectionHeader from './CollectionHeader'; import RequestTab from './RequestTab'; import StyledWrapper from './StyledWrapper'; import DraggableTab from './DraggableTab'; @@ -27,6 +27,7 @@ const RequestTabs = () => { const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth); const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed); const screenWidth = useSelector((state) => state.app.screenWidth); + const workspaces = useSelector((state) => state.workspaces.workspaces); const createSetHasOverflow = useCallback((tabUid) => { return (hasOverflow) => { @@ -46,6 +47,10 @@ const RequestTabs = () => { const activeCollection = find(collections, (c) => c?.uid === activeTab?.collectionUid); const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid); + const isScratchCollection = useMemo(() => { + return activeCollection ? workspaces.some((w) => w.scratchCollectionUid === activeCollection.uid) : false; + }, [workspaces, activeCollection]); + useEffect(() => { if (!activeTabUid || !activeTab) return; @@ -110,7 +115,12 @@ const RequestTabs = () => { )} {collectionRequestTabs && collectionRequestTabs.length ? ( <> - {activeCollection && } + {activeCollection && ( + + )}
diff --git a/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js b/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js new file mode 100644 index 000000000..cb9826013 --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js @@ -0,0 +1,43 @@ +import React, { useMemo, useCallback, memo } from 'react'; +import { useSelector } from 'react-redux'; +import { IconDatabase, IconCheck, IconLoader2 } from '@tabler/icons'; +import { areItemsLoading } from 'utils/collections'; + +const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => { + const collection = useSelector((state) => + state.collections.collections.find((c) => c.uid === collectionUid || c.pathname === collectionPath) + ); + + const { isFullyLoaded, isLoading } = useMemo(() => { + const isMounted = collection?.mountStatus === 'mounted'; + const fullyLoaded = isMounted && !areItemsLoading(collection); + const loading = isSelected && !fullyLoaded; + return { isFullyLoaded: fullyLoaded, isLoading: loading }; + }, [collection, isSelected]); + + const handleClick = useCallback(() => { + if (!isLoading) { + onSelect(); + } + }, [isLoading, onSelect]); + + return ( +
  • +
    + + {collectionName} +
    + {isLoading && ( + + )} + {isFullyLoaded && ( + + )} +
  • + ); +}); + +export default CollectionListItem; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/FolderBreadcrumbs/index.js b/packages/bruno-app/src/components/SaveTransientRequest/FolderBreadcrumbs/index.js new file mode 100644 index 000000000..f11b48bee --- /dev/null +++ b/packages/bruno-app/src/components/SaveTransientRequest/FolderBreadcrumbs/index.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { IconChevronRight } from '@tabler/icons'; + +const FolderBreadcrumbs = ({ + collectionName, + breadcrumbs, + isAtRoot, + onNavigateToRoot, + onNavigateToBreadcrumb +}) => { + return ( + <> + + {collectionName} + + {breadcrumbs.map((breadcrumb, index) => ( + + + { + e.stopPropagation(); + onNavigateToBreadcrumb(index); + }} + > + {breadcrumb.name} + + + ))} + {isAtRoot && } + + ); +}; + +export default FolderBreadcrumbs; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js index a8e422801..90794cfb0 100644 --- a/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js +++ b/packages/bruno-app/src/components/SaveTransientRequest/StyledWrapper.js @@ -127,6 +127,79 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.colors.text.muted}; } + .collection-list { + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: ${(props) => props.theme.border.radius.sm}; + max-height: 320px; + overflow-y: auto; + background-color: ${(props) => props.theme.modal.body.bg}; + padding: 8px 8px; + } + + .collection-list-items { + display: flex; + flex-direction: column; + gap: 4px; + list-style: none; + padding: 0; + margin: 0; + border-radius: ${(props) => props.theme.border.radius.sm}; + } + + .collection-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + cursor: pointer; + transition: background-color 0.15s ease; + color: ${(props) => props.theme.text}; + border-radius: ${(props) => props.theme.border.radius.sm}; + user-select: none; + border: 1px solid ${(props) => props.theme.border.border1}; + + &:hover { + background-color: ${(props) => props.theme.plainGrid.hoverBg}; + border-color: ${(props) => props.theme.colors.text.muted}; + } + } + + .collection-item-content { + display: flex; + align-items: center; + gap: 10px; + } + + .collection-item-name { + color: ${(props) => props.theme.text}; + font-weight: 500; + } + + .collection-empty-state { + padding: 20px 16px; + text-align: center; + font-size: 14px; + color: ${(props) => props.theme.colors.text.muted}; + line-height: 1.5; + } + + .animate-spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + .icon-success { + color: ${(props) => props.theme.colors.success}; + } + .custom-modal-footer { display: flex; justify-content: space-between; diff --git a/packages/bruno-app/src/components/SaveTransientRequest/index.js b/packages/bruno-app/src/components/SaveTransientRequest/index.js index 1660a3318..994672ca9 100644 --- a/packages/bruno-app/src/components/SaveTransientRequest/index.js +++ b/packages/bruno-app/src/components/SaveTransientRequest/index.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect, useRef } from 'react'; +import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import Modal from 'components/Modal'; import SearchInput from 'components/SearchInput'; @@ -9,14 +9,16 @@ import Help from 'components/Help'; import filter from 'lodash/filter'; import toast from 'react-hot-toast'; import StyledWrapper from './StyledWrapper'; +import CollectionListItem from './CollectionListItem'; +import FolderBreadcrumbs from './FolderBreadcrumbs'; import useCollectionFolderTree from 'hooks/useCollectionFolderTree'; -import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections'; +import { removeSaveTransientRequestModal } from 'providers/ReduxStore/slices/collections'; import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app'; -import { newFolder, closeTabs } from 'providers/ReduxStore/slices/collections/actions'; +import { newFolder, closeTabs, mountCollection } from 'providers/ReduxStore/slices/collections/actions'; import { sanitizeName, validateName, validateNameError } from 'utils/common/regex'; import { resolveRequestFilename } from 'utils/common/platform'; import path from 'utils/common/path'; -import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections'; +import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } from 'utils/collections'; import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants'; import { itemSchema } from '@usebruno/schema'; import { uuid } from 'utils/common'; @@ -33,12 +35,27 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp const item = itemProp; const collection = collectionProp; + const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const allCollections = useSelector((state) => state.collections.collections); + const isScratchCollection = activeWorkspace?.scratchCollectionUid === collection?.uid; + + const availableCollections = useMemo(() => { + if (!isScratchCollection || !activeWorkspace) return []; + + return (activeWorkspace.collections || []).map((wc) => { + const fullCollection = allCollections.find((c) => c.pathname === wc.path); + // Use stable deterministic UID based on path to avoid duplicate Redux entries + const stableUid = wc.path ? `pending-${wc.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuid(); + return fullCollection || { ...wc, uid: stableUid, mountStatus: 'unmounted' }; + }).filter((c) => !workspaces.some((w) => w.scratchCollectionUid === c.uid)); + }, [isScratchCollection, activeWorkspace, allCollections, workspaces]); + const handleClose = () => { if (onClose) { onClose(); return; } - // Remove from Redux array dispatch(removeSaveTransientRequestModal({ itemUid: item.uid })); }; const [requestName, setRequestName] = useState(item?.name || ''); @@ -51,6 +68,24 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null); const newFolderInputRef = useRef(null); + const [selectedTargetCollectionPath, setSelectedTargetCollectionPath] = useState(null); + const [isSelectingCollection, setIsSelectingCollection] = useState(isScratchCollection); + const folderTreeCollectionUid = selectedTargetCollectionPath + ? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)?.uid + : collection?.uid; + + const selectedTargetCollection = selectedTargetCollectionPath + ? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath) + : null; + + useEffect(() => { + const isMounted = selectedTargetCollection?.mountStatus === 'mounted'; + const isFullyLoaded = isMounted && !areItemsLoading(selectedTargetCollection); + if (selectedTargetCollectionPath && isFullyLoaded) { + setIsSelectingCollection(false); + } + }, [selectedTargetCollectionPath, selectedTargetCollection]); + const { currentFolders, breadcrumbs, @@ -62,10 +97,10 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp getCurrentSelectedFolder, reset, isAtRoot - } = useCollectionFolderTree(collection?.uid); + } = useCollectionFolderTree(folderTreeCollectionUid); - const resetForm = () => { - setRequestName(item.name || ''); + const resetForm = useCallback(() => { + setRequestName(item?.name || ''); setSearchText(''); reset(); setShowNewFolderInput(false); @@ -74,11 +109,15 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp setShowFilesystemName(false); setIsEditingFolderFilename(false); setPendingFolderNavigation(null); - }; + setSelectedTargetCollectionPath(null); + setIsSelectingCollection(isScratchCollection); + }, [item?.name, isScratchCollection, reset]); useEffect(() => { - isOpen && item && resetForm(); - }, [isOpen, item]); + if (isOpen && item) { + resetForm(); + } + }, [isOpen, item, resetForm]); useEffect(() => { if (showNewFolderInput && newFolderInputRef.current) { @@ -86,7 +125,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp } }, [showNewFolderInput]); - // Auto-navigate into newly created folder when it appears in currentFolders useEffect(() => { if (pendingFolderNavigation) { const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation); @@ -110,16 +148,41 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp handleClose(); }; + const handleSelectCollection = useCallback((selectedCollection) => { + const collectionPath = selectedCollection.path || selectedCollection.pathname; + const isMounted = selectedCollection.mountStatus === 'mounted'; + const isFullyLoaded = isMounted && !areItemsLoading(selectedCollection); + + setSelectedTargetCollectionPath(collectionPath); + + if (isFullyLoaded) { + setIsSelectingCollection(false); + return; + } + + if (!isMounted && selectedCollection.mountStatus !== 'mounting') { + dispatch( + mountCollection({ + collectionUid: selectedCollection.uid || uuid(), + collectionPathname: collectionPath, + brunoConfig: selectedCollection.brunoConfig + }) + ); + } + }, [dispatch]); + const handleConfirm = async () => { if (!item || !collection || !latestItem) { return; } + const targetCollection = selectedTargetCollection || collection; + try { const { ipcRenderer } = window; const selectedFolder = getCurrentSelectedFolder(); - const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname; + const targetDirname = selectedFolder ? selectedFolder.pathname : targetCollection.pathname; const trimmedName = requestName.trim(); if (!trimmedName || trimmedName.length === 0) { @@ -141,8 +204,9 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp const transformedItem = transformRequestToSaveToFilesystem(itemToSave); await itemSchema.validate(transformedItem); - const format = collection.format || DEFAULT_COLLECTION_FORMAT; - const targetFilename = resolveRequestFilename(sanitizedFilename, format); + const targetFormat = targetCollection.format || DEFAULT_COLLECTION_FORMAT; + const sourceFormat = collection.format || DEFAULT_COLLECTION_FORMAT; + const targetFilename = resolveRequestFilename(sanitizedFilename, targetFormat); const targetPathname = path.join(targetDirname, targetFilename); await ipcRenderer.invoke('renderer:save-transient-request', { @@ -150,15 +214,15 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp targetDirname, targetFilename, request: transformedItem, - format + format: targetFormat, + sourceFormat }); - // Add task to open the newly saved request when file watcher detects it dispatch( insertTaskIntoQueue({ uid: uuid(), type: 'OPEN_REQUEST', - collectionUid: collection.uid, + collectionUid: targetCollection.uid, itemPathname: targetPathname, preview: false }) @@ -220,12 +284,12 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp const directoryName = newFolderDirectoryName.trim() || sanitizeName(trimmedFolderName); const parentFolder = getCurrentParentFolder(); + const targetCollectionUid = selectedTargetCollection?.uid || collection?.uid; try { - await dispatch(newFolder(trimmedFolderName, directoryName, collection?.uid, parentFolder?.uid)); + await dispatch(newFolder(trimmedFolderName, directoryName, targetCollectionUid, parentFolder?.uid)); toast.success('New folder created!'); - // Set pending navigation - useEffect will navigate when folder appears in state setPendingFolderNavigation(directoryName); handleCancelNewFolder(); } catch (err) { @@ -239,6 +303,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp setSearchText(''); }; + const handleBreadcrumbNavigate = useCallback((index) => { + navigateToBreadcrumb(index); + setSearchText(''); + }, [navigateToBreadcrumb]); + if (!isOpen) { return null; } @@ -247,7 +316,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp setRequestName(e.target.value)} - autoFocus={true} + autoFocus={!isSelectingCollection} onFocus={(e) => e.target.select()} />
    -
    Save to Collections
    - {collection && ( -
    - {collection.name} - {breadcrumbs.length > 0 && ( +
    + {isSelectingCollection ? 'Select a collection to save to' : 'Save to Collections'} +
    + + {isScratchCollection && ( +
    + { + setIsSelectingCollection(true); + setSelectedTargetCollectionPath(null); + reset(); + } : undefined} + > + Collections + + {!isSelectingCollection && ( <> - {breadcrumbs.map((breadcrumb, index) => ( - - - { - e.stopPropagation(); - navigateToBreadcrumb(index); - setSearchText(''); - }} - > - {breadcrumb.name} - - - ))} + + )} - {isAtRoot && }
    )} -
    - -
    - -
    - {filteredFolders.length > 0 || showNewFolderInput ? ( -
      - {filteredFolders.map((folder) => ( -
    • handleFolderClick(folder.uid)} - > -
      - - {folder.name} -
      - -
    • - ))} - {showNewFolderInput && ( -
    • -
      - - -
      -
      - handleNewFolderNameChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - handleCreateNewFolder(); - } else if (e.key === 'Escape') { - e.stopPropagation(); - handleCancelNewFolder(); - } - }} + {isSelectingCollection ? ( +
      + {availableCollections.length > 0 ? ( +
        + {availableCollections.map((coll) => { + const collPath = coll.path || coll.pathname; + return ( + handleSelectCollection(coll)} /> -
        - - -
        -
      + ); + })} +
    + ) : ( +
    + No collections available in workspace. Please add a collection to the workspace first. +
    + )} +
    + ) : ( + <> + {!isScratchCollection && (selectedTargetCollection || collection) && ( +
    + +
    + )} - {showFilesystemName && ( -
    -
    - - {isEditingFolderFilename ? ( - setIsEditingFolderFilename(false)} - /> - ) : ( - setIsEditingFolderFilename(true)} - /> - )} +
    + +
    + +
    + {filteredFolders.length > 0 || showNewFolderInput ? ( +
      + {filteredFolders.map((folder) => ( +
    • handleFolderClick(folder.uid)} + > +
      + + {folder.name}
      - {isEditingFolderFilename ? ( -
      - setNewFolderDirectoryName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - handleCreateNewFolder(); - } else if (e.key === 'Escape') { - e.stopPropagation(); - handleCancelNewFolder(); - } - }} - /> + +
    • + ))} + {showNewFolderInput && ( +
    • +
      + + +
      +
      + handleNewFolderNameChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleCreateNewFolder(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + handleCancelNewFolder(); + } + }} + /> +
      + +
      - ) : ( -
      - +
      + + {showFilesystemName && ( +
      +
      + + {isEditingFolderFilename ? ( + setIsEditingFolderFilename(false)} + /> + ) : ( + setIsEditingFolderFilename(true)} + /> + )} +
      + {isEditingFolderFilename ? ( +
      + setNewFolderDirectoryName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleCreateNewFolder(); + } else if (e.key === 'Escape') { + e.stopPropagation(); + handleCancelNewFolder(); + } + }} + /> +
      + ) : ( +
      + +
      + )}
      )} -
      - )} - -
    • + + + )} +
    + ) : ( +
    + {searchText.trim() ? 'No folders found' : 'No folders available'} +
    )} - - ) : ( -
    - {searchText.trim() ? 'No folders found' : 'No folders available'}
    - )} -
    + + )}
    - {!showNewFolderInput && ( + {!showNewFolderInput && !isSelectingCollection && ( - + {!isSelectingCollection && ( + + )}
    diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js index 60ac62a6a..08b94f5e8 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js @@ -1,12 +1,12 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { useSelector } from 'react-redux'; import Collection from './Collection'; import CreateCollection from '../CreateCollection'; import StyledWrapper from './StyledWrapper'; import CreateOrOpenCollection from './CreateOrOpenCollection'; import CollectionSearch from './CollectionSearch/index'; -import { useMemo } from 'react'; import { normalizePath } from 'utils/common/path'; +import { isScratchCollection } from 'utils/collections'; const Collections = ({ showSearch }) => { const [searchText, setSearchText] = useState(''); @@ -18,10 +18,14 @@ const Collections = ({ showSearch }) => { const workspaceCollections = useMemo(() => { if (!activeWorkspace) return []; - return collections.filter((c) => - activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname)) - ); - }, [activeWorkspace, collections]); + + return collections.filter((c) => { + if (isScratchCollection(c, workspaces)) { + return false; + } + return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname)); + }); + }, [activeWorkspace, collections, workspaces]); if (!workspaceCollections || !workspaceCollections.length) { return ( diff --git a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js index d3fec1217..30b7a3ca9 100644 --- a/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Sections/CollectionsSection/index.js @@ -18,6 +18,7 @@ import { import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions'; import { sortCollections } from 'providers/ReduxStore/slices/collections/index'; import { normalizePath } from 'utils/common/path'; +import { isScratchCollection } from 'utils/collections'; import MenuDropdown from 'ui/MenuDropdown'; import ActionIcon from 'ui/ActionIcon'; @@ -47,10 +48,14 @@ const CollectionsSection = () => { const workspaceCollections = useMemo(() => { if (!activeWorkspace) return []; - return collections.filter((c) => - activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname)) - ); - }, [activeWorkspace, collections]); + + return collections.filter((c) => { + if (isScratchCollection(c, workspaces)) { + return false; + } + return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname)); + }); + }, [activeWorkspace, collections, workspaces]); const handleImportCollection = ({ rawData, type, ...rest }) => { setImportCollectionModalOpen(false); diff --git a/packages/bruno-app/src/components/StatusBar/index.js b/packages/bruno-app/src/components/StatusBar/index.js index 636c71ffc..7a44ef1db 100644 --- a/packages/bruno-app/src/components/StatusBar/index.js +++ b/packages/bruno-app/src/components/StatusBar/index.js @@ -10,7 +10,6 @@ import Notifications from 'components/Notifications'; import Portal from 'components/Portal'; import ThemeDropdown from './ThemeDropdown'; import { openConsole } from 'providers/ReduxStore/slices/logs'; -import { setActiveWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs'; import { addTab } from 'providers/ReduxStore/slices/tabs'; import { useApp } from 'providers/App'; import StyledWrapper from './StyledWrapper'; @@ -18,6 +17,7 @@ import StyledWrapper from './StyledWrapper'; const StatusBar = () => { const dispatch = useDispatch(); const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid); + const workspaces = useSelector((state) => state.workspaces.workspaces); const showHomePage = useSelector((state) => state.app.showHomePage); const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage); const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage); @@ -28,6 +28,8 @@ const StatusBar = () => { const [cookiesOpen, setCookiesOpen] = useState(false); const { version } = useApp(); + const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); + const errorCount = logs.filter((log) => log.type === 'error').length; const handleConsoleClick = () => { @@ -35,19 +37,15 @@ const StatusBar = () => { }; const handlePreferencesClick = () => { - if (showHomePage || showManageWorkspacePage || showApiSpecPage || !activeTabUid) { - if (activeWorkspaceUid) { - dispatch(setActiveWorkspaceTab({ workspaceUid: activeWorkspaceUid, type: 'preferences' })); - } - } else { - dispatch( - addTab({ - type: 'preferences', - uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences', - collectionUid: activeTab?.collectionUid - }) - ); - } + const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid; + + dispatch( + addTab({ + type: 'preferences', + uid: collectionUid ? `${collectionUid}-preferences` : 'preferences', + collectionUid: collectionUid + }) + ); }; const openGlobalSearch = () => { diff --git a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js deleted file mode 100644 index 7c098b114..000000000 --- a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js +++ /dev/null @@ -1,110 +0,0 @@ -import styled from 'styled-components'; -import { rgba } from 'polished'; - -const StyledWrapper = styled.div` - .main-content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - } - - .workspace-header { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 16px; - position: relative; - } - - .workspace-title { - display: flex; - align-items: center; - gap: 8px; - height: 24px; - font-size: 15px; - font-weight: 600; - color: ${(props) => props.theme.text}; - } - - .workspace-rename-container { - height: 24px; - display: flex; - align-items: center; - background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; - gap: 6px; - border-radius: 4px; - } - - .workspace-name-input { - padding: 0 8px; - font-size: 14px; - font-weight: 600; - border-radius: 4px; - background: transparent; - color: ${(props) => props.theme.text}; - outline: none; - min-width: 180px; - - &:focus { - outline: none; - } - } - - .inline-actions { - display: flex; - gap: 2px; - } - - .inline-action-btn { - display: flex; - align-items: center; - justify-content: center; - padding: 4px; - border-radius: 4px; - cursor: pointer; - transition: all 0.15s; - - &.save { - color: ${(props) => props.theme.colors.text.green}; - - &:hover { - background-color: ${(props) => rgba(props.theme.colors.text.green, 0.1)}; - } - } - - &.cancel { - color: ${(props) => props.theme.colors.text.danger}; - - &:hover { - background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)}; - } - } - } - - .workspace-error { - position: absolute; - top: 80%; - left: 40px; - z-index: 10; - margin-top: 4px; - padding: 4px 8px; - font-size: 11px; - color: ${(props) => props.theme.colors.text.danger}; - background: ${(props) => props.theme.bg}; - border: 1px solid ${(props) => props.theme.colors.text.danger}; - border-radius: 4px; - white-space: nowrap; - } - - .workspace-menu-dropdown { - min-width: 140px; - } - - .tab-content { - flex: 1; - overflow: hidden; - } -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js index 421eb5803..fc5eb2351 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js @@ -28,7 +28,14 @@ const CollectionsList = ({ workspace }) => { return []; } - return workspace.collections.map((wc) => { + const filteredCollections = workspace.collections.filter((wc) => { + if (workspace.scratchTempDirectory) { + return normalizePath(wc.path) !== normalizePath(workspace.scratchTempDirectory); + } + return true; + }); + + return filteredCollections.map((wc) => { const loadedCollection = collections.find( (c) => normalizePath(c.pathname) === normalizePath(wc.path) ); @@ -64,7 +71,7 @@ const CollectionsList = ({ workspace }) => { } }; }); - }, [workspace.collections, collections]); + }, [workspace.collections, workspace.scratchTempDirectory, collections]); const handleOpenCollectionClick = (collection, event) => { if (event.target.closest('.collection-menu')) { diff --git a/packages/bruno-app/src/components/WorkspaceHome/index.js b/packages/bruno-app/src/components/WorkspaceHome/index.js deleted file mode 100644 index ff6220cbf..000000000 --- a/packages/bruno-app/src/components/WorkspaceHome/index.js +++ /dev/null @@ -1,262 +0,0 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import { IconCategory, IconDots, IconEdit, IconX, IconCheck, IconFolder, IconUpload } from '@tabler/icons'; -import { renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions'; -import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; -import toast from 'react-hot-toast'; -import CloseWorkspace from 'components/Sidebar/CloseWorkspace'; -import WorkspaceOverview from './WorkspaceOverview'; -import WorkspaceEnvironments from './WorkspaceEnvironments'; -import Preferences from 'components/Preferences'; -import WorkspaceTabs from 'components/WorkspaceTabs'; -import StyledWrapper from './StyledWrapper'; -import Dropdown from 'components/Dropdown'; -import { getRevealInFolderLabel } from 'utils/common/platform'; -import { getWorkspaceDisplayName } from 'components/AppTitleBar'; -import classNames from 'classnames'; - -const WorkspaceHome = () => { - const dispatch = useDispatch(); - const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); - const workspaceTabs = useSelector((state) => state.workspaceTabs); - const activeTabUid = workspaceTabs.activeTabUid; - const activeTab = workspaceTabs.tabs.find((t) => t.uid === activeTabUid); - - const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false); - const [workspaceNameInput, setWorkspaceNameInput] = useState(''); - const [workspaceNameError, setWorkspaceNameError] = useState(''); - const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false); - const workspaceNameInputRef = useRef(null); - const workspaceRenameContainerRef = useRef(null); - const dropdownTippyRef = useRef(); - const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); - - const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid); - - useEffect(() => { - if (!isRenamingWorkspace) return; - - const handleClickOutside = (event) => { - if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) { - handleCancelWorkspaceRename(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [isRenamingWorkspace]); - - if (!activeWorkspace) { - return null; - } - - const handleRenameWorkspaceClick = () => { - dropdownTippyRef.current?.hide(); - setIsRenamingWorkspace(true); - setWorkspaceNameInput(activeWorkspace.name); - setWorkspaceNameError(''); - setTimeout(() => { - workspaceNameInputRef.current?.focus(); - workspaceNameInputRef.current?.select(); - }, 50); - }; - - const handleCloseWorkspaceClick = () => { - dropdownTippyRef.current?.hide(); - if (activeWorkspace.type === 'default') { - toast.error('Cannot close the default workspace'); - return; - } - setCloseWorkspaceModalOpen(true); - }; - - const handleShowInFolder = () => { - dropdownTippyRef.current?.hide(); - if (activeWorkspace.pathname) { - dispatch(showInFolder(activeWorkspace.pathname)).catch((error) => { - toast.error('Error opening the folder'); - }); - } - }; - - const handleExportWorkspace = () => { - dropdownTippyRef.current?.hide(); - dispatch(exportWorkspaceAction(activeWorkspace.uid)) - .then((result) => { - if (!result.canceled) { - toast.success('Workspace exported successfully'); - } - }) - .catch((error) => { - toast.error(error?.message || 'Error exporting workspace'); - }); - }; - - const validateWorkspaceName = (name) => { - if (!name || name.trim() === '') { - return 'Name is required'; - } - if (name.length < 1) { - return 'Must be at least 1 character'; - } - if (name.length > 255) { - return 'Must be 255 characters or less'; - } - return null; - }; - - const handleSaveWorkspaceRename = () => { - const error = validateWorkspaceName(workspaceNameInput); - if (error) { - setWorkspaceNameError(error); - return; - } - - dispatch(renameWorkspaceAction(activeWorkspace.uid, workspaceNameInput)) - .then(() => { - toast.success('Workspace renamed!'); - setIsRenamingWorkspace(false); - setWorkspaceNameInput(''); - setWorkspaceNameError(''); - }) - .catch((err) => { - toast.error(err?.message || 'An error occurred while renaming the workspace'); - setWorkspaceNameError(err?.message || 'Failed to rename workspace'); - }); - }; - - const handleCancelWorkspaceRename = () => { - setIsRenamingWorkspace(false); - setWorkspaceNameInput(''); - setWorkspaceNameError(''); - }; - - const handleWorkspaceNameChange = (e) => { - setWorkspaceNameInput(e.target.value); - if (workspaceNameError) { - setWorkspaceNameError(''); - } - }; - - const handleWorkspaceNameKeyDown = (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - handleSaveWorkspaceRename(); - } else if (e.key === 'Escape') { - e.preventDefault(); - handleCancelWorkspaceRename(); - } - }; - - const renderTabContent = () => { - if (!activeTab) return null; - - switch (activeTab.type) { - case 'overview': - return ; - case 'environments': - return ; - case 'preferences': - return ; - default: - return null; - } - }; - - return ( - -
    - {closeWorkspaceModalOpen && ( - setCloseWorkspaceModalOpen(false)} - /> - )} - -
    -
    -
    - - {isRenamingWorkspace ? ( -
    - -
    - - -
    -
    - ) : ( - {getWorkspaceDisplayName(activeWorkspace.name)} - )} -
    - - {!isRenamingWorkspace && activeWorkspace.type !== 'default' && ( - } - > -
    -
    - - Rename -
    -
    - - {getRevealInFolderLabel()} -
    -
    - - Export -
    -
    - - Close -
    -
    -
    - )} - - {workspaceNameError && isRenamingWorkspace && ( -
    {workspaceNameError}
    - )} -
    - - - -
    {renderTabContent()}
    -
    -
    -
    - ); -}; - -export default WorkspaceHome; diff --git a/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js deleted file mode 100644 index 8f394f0c8..000000000 --- a/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js +++ /dev/null @@ -1,197 +0,0 @@ -import styled from 'styled-components'; - -const Wrapper = styled.div` - position: relative; - - &::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 1px; - background: ${(props) => props.theme.requestTabs.bottomBorder}; - z-index: 0; - } - - .tabs-scroll-container { - overflow-x: auto; - overflow-y: clip; - padding-bottom: 10px; - margin-bottom: -10px; - - &::-webkit-scrollbar { - display: none; - } - - scrollbar-width: none; - - ul { - margin-bottom: 0; - overflow: visible; - } - } - - ul { - padding: 0 3px; - margin: 0; - display: flex; - align-items: flex-end; - position: relative; - - &::-webkit-scrollbar { - display: none; - } - - scrollbar-width: none; - - li { - display: inline-flex; - max-width: 180px; - min-width: 80px; - list-style: none; - cursor: pointer; - font-size: 0.8125rem; - position: relative; - margin-right: 3px; - color: ${(props) => props.theme.requestTabs.color}; - background: transparent; - border: 1px solid transparent; - padding: 6px 0; - flex-shrink: 0; - margin-bottom: 3px; - - .tab-container { - width: 100%; - position: relative; - overflow: hidden; - } - - &:not(.active) { - background: ${(props) => props.theme.requestTabs.bg}; - border-color: transparent; - border-radius: ${(props) => props.theme.border.radius.base}; - } - - &:nth-last-child(1) { - margin-right: 4px; - } - - &.has-overflow:not(:hover) .tab-name { - mask-image: linear-gradient( - to right, - ${(props) => props.theme.requestTabs.color} 0%, - ${(props) => props.theme.requestTabs.color} calc(100% - 12px), - transparent 100% - ); - -webkit-mask-image: linear-gradient( - to right, - ${(props) => props.theme.requestTabs.color} 0%, - ${(props) => props.theme.requestTabs.color} calc(100% - 12px), - transparent 100% - ); - } - - &.has-overflow:hover .tab-name { - mask-image: linear-gradient( - to right, - ${(props) => props.theme.requestTabs.color} 0%, - ${(props) => props.theme.requestTabs.color} calc(100% - 8px), - transparent 100% - ); - -webkit-mask-image: linear-gradient( - to right, - ${(props) => props.theme.requestTabs.color} 0%, - ${(props) => props.theme.requestTabs.color} calc(100% - 8px), - transparent 100% - ); - } - - &.active { - background: ${(props) => props.theme.bg || '#ffffff'}; - border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - border-bottom-color: ${(props) => props.theme.bg || '#ffffff'}; - border-radius: 8px 8px 0 0; - z-index: 2; - margin-bottom: -2px; - padding-bottom: 12px; - - &::before { - content: ''; - position: absolute; - bottom: 1px; - left: -8px; - width: 8px; - height: 8px; - background: transparent; - border-bottom-right-radius: 6px; - box-shadow: 3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'}; - border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - } - - &::after { - content: ''; - position: absolute; - bottom: 1px; - right: -8px; - width: 8px; - height: 8px; - background: transparent; - border-bottom-left-radius: 6px; - box-shadow: -3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'}; - border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; - } - } - - &.permanent-tab { - .close-icon { - display: none; - } - } - - &.short-tab { - width: 32px; - min-width: 32px; - max-width: 32px; - padding: 5px 0; - display: inline-flex; - justify-content: center; - align-items: center; - color: ${(props) => props.theme.text}; - background-color: transparent; - border: 1px solid transparent; - border-radius: ${(props) => props.theme.border.radius.base}; - flex-shrink: 0; - - > div { - padding: 3px; - display: flex; - align-items: center; - justify-content: center; - border-radius: ${(props) => props.theme.border.radius.sm}; - transition: background-color 0.12s ease, color 0.12s ease; - } - - svg { - height: 20px; - width: 20px; - } - - &:hover { - > div { - background-color: ${(props) => props.theme.background.surface0}; - color: ${(props) => props.theme.text}; - } - } - } - } - } - - &.has-chevrons ul { - padding-left: 0; - } -`; - -export default Wrapper; diff --git a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js deleted file mode 100644 index cadfbb82a..000000000 --- a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js +++ /dev/null @@ -1,61 +0,0 @@ -import styled from 'styled-components'; - -const StyledWrapper = styled.div` - position: relative; - width: 100%; - height: 100%; - - .tab-label { - overflow: hidden; - align-items: center; - position: relative; - flex: 1; - min-width: 0; - } - - .tab-icon { - flex-shrink: 0; - display: flex; - align-items: center; - margin-right: 6px; - color: ${(props) => props.theme.requestTabs.color}; - } - - .tab-name { - position: relative; - overflow: hidden; - white-space: nowrap; - font-size: 0.8125rem; - padding-right: 2px; - } - - .close-icon { - margin-left: 6px; - padding: 2px; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - opacity: 0; - transition: opacity 0.15s, background-color 0.15s; - - &:hover { - background-color: ${(props) => props.theme.requestTabs.closeIconHoverBg || 'rgba(0, 0, 0, 0.1)'}; - } - - svg { - width: 14px; - height: 14px; - } - } - - &:hover .close-icon { - opacity: 1; - } - - &.permanent .close-icon { - display: none; - } -`; - -export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js deleted file mode 100644 index 0d4d256ba..000000000 --- a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { IconX, IconHome, IconWorld, IconSettings } from '@tabler/icons'; -import { useDispatch } from 'react-redux'; -import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs'; -import StyledWrapper from './StyledWrapper'; - -const TAB_ICONS = { - overview: IconHome, - environments: IconWorld, - preferences: IconSettings -}; - -const WorkspaceTab = ({ tab, isActive }) => { - const dispatch = useDispatch(); - - const handleCloseClick = (event) => { - event.stopPropagation(); - event.preventDefault(); - dispatch(closeWorkspaceTab({ uid: tab.uid })); - }; - - const TabIcon = TAB_ICONS[tab.type]; - - return ( - -
    - {TabIcon && ( - - - - )} - - {tab.label} - -
    - {!tab.permanent && ( -
    - -
    - )} -
    - ); -}; - -export default WorkspaceTab; diff --git a/packages/bruno-app/src/components/WorkspaceTabs/index.js b/packages/bruno-app/src/components/WorkspaceTabs/index.js deleted file mode 100644 index e579f572d..000000000 --- a/packages/bruno-app/src/components/WorkspaceTabs/index.js +++ /dev/null @@ -1,158 +0,0 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import filter from 'lodash/filter'; -import classnames from 'classnames'; -import { IconChevronRight, IconChevronLeft } from '@tabler/icons'; -import { useSelector, useDispatch } from 'react-redux'; -import { focusWorkspaceTab, initializeWorkspaceTabs } from 'providers/ReduxStore/slices/workspaceTabs'; -import WorkspaceTab from './WorkspaceTab'; -import StyledWrapper from './StyledWrapper'; - -const PERMANENT_TABS = [ - { type: 'overview', label: 'Overview' }, - { type: 'environments', label: 'Global Environments' } -]; - -const WorkspaceTabs = ({ workspaceUid }) => { - const dispatch = useDispatch(); - const tabsRef = useRef(); - const scrollContainerRef = useRef(); - const [tabOverflowStates, setTabOverflowStates] = useState({}); - const [showChevrons, setShowChevrons] = useState(false); - - const tabs = useSelector((state) => state.workspaceTabs.tabs); - const activeTabUid = useSelector((state) => state.workspaceTabs.activeTabUid); - const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth); - const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed); - const screenWidth = useSelector((state) => state.app.screenWidth); - - // Initialize permanent tabs for this workspace - useEffect(() => { - if (workspaceUid) { - dispatch(initializeWorkspaceTabs({ - workspaceUid, - permanentTabs: PERMANENT_TABS - })); - } - }, [workspaceUid, dispatch]); - - const createSetHasOverflow = useCallback((tabUid) => { - return (hasOverflow) => { - setTabOverflowStates((prev) => { - if (prev[tabUid] === hasOverflow) { - return prev; - } - return { - ...prev, - [tabUid]: hasOverflow - }; - }); - }; - }, []); - - // Filter tabs for this workspace - const workspaceTabs = filter(tabs, (t) => t.workspaceUid === workspaceUid); - - useEffect(() => { - if (!activeTabUid) return; - - const checkOverflow = () => { - if (tabsRef.current && scrollContainerRef.current) { - const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth + 1; - setShowChevrons(hasOverflow); - } - }; - - checkOverflow(); - const resizeObserver = new ResizeObserver(checkOverflow); - if (scrollContainerRef.current) { - resizeObserver.observe(scrollContainerRef.current); - } - - return () => resizeObserver.disconnect(); - }, [activeTabUid, workspaceTabs.length, screenWidth, leftSidebarWidth, sidebarCollapsed]); - - const getTabClassname = (tab, index) => { - return classnames('request-tab select-none', { - 'active': tab.uid === activeTabUid, - 'permanent-tab': tab.permanent, - 'last-tab': workspaceTabs && workspaceTabs.length && index === workspaceTabs.length - 1, - 'has-overflow': tabOverflowStates[tab.uid] - }); - }; - - const handleClick = (tab) => { - dispatch(focusWorkspaceTab({ uid: tab.uid })); - }; - - if (!workspaceUid || workspaceTabs.length === 0) { - return null; - } - - const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth; - const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150; - - const leftSlide = () => { - scrollContainerRef.current?.scrollBy({ - left: -120, - behavior: 'smooth' - }); - }; - - const rightSlide = () => { - scrollContainerRef.current?.scrollBy({ - left: 120, - behavior: 'smooth' - }); - }; - - const getRootClassname = () => { - return classnames({ - 'has-chevrons': showChevrons - }); - }; - - return ( - -
    -
      - {showChevrons ? ( -
    • -
      - -
      -
    • - ) : null} -
    -
    -
      - {workspaceTabs.map((tab, index) => ( -
    • handleClick(tab)} - > - -
    • - ))} -
    -
    -
      - {showChevrons ? ( -
    • -
      - -
      -
    • - ) : null} -
    -
    -
    - ); -}; - -export default WorkspaceTabs; diff --git a/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js index f7663777d..e2f2e234c 100644 --- a/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js +++ b/packages/bruno-app/src/hooks/useCollectionFolderTree/index.js @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { isItemAFolder } from 'utils/collections'; import { sortByNameThenSequence } from 'utils/common/index'; import filter from 'lodash/filter'; @@ -63,6 +63,7 @@ const useCollectionFolderTree = (collectionUid) => { const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid)); const [currentFolderPath, setCurrentFolderPath] = useState([]); const [selectedFolderUid, setSelectedFolderUid] = useState(null); + const tree = useMemo(() => { if (!collection || !collection.items) { return {}; @@ -143,6 +144,10 @@ const useCollectionFolderTree = (collectionUid) => { setSelectedFolderUid(null); }, []); + useEffect(() => { + reset(); + }, [collectionUid, reset]); + return { currentFolders, breadcrumbs, diff --git a/packages/bruno-app/src/pages/Bruno/index.js b/packages/bruno-app/src/pages/Bruno/index.js index 91a5b80a8..35275bb29 100644 --- a/packages/bruno-app/src/pages/Bruno/index.js +++ b/packages/bruno-app/src/pages/Bruno/index.js @@ -1,6 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; import classnames from 'classnames'; -import WorkspaceHome from 'components/WorkspaceHome'; import ManageWorkspace from 'components/ManageWorkspace'; import RequestTabs from 'components/RequestTabs'; import RequestTabPanel from 'components/RequestTabPanel'; @@ -77,7 +76,6 @@ export default function Main() { const activeTabUid = useSelector((state) => state.tabs.activeTabUid); const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid); const isDragging = useSelector((state) => state.app.isDragging); - const showHomePage = useSelector((state) => state.app.showHomePage); const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage); const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage); const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen); @@ -144,8 +142,6 @@ export default function Main() { ) : showManageWorkspacePage ? ( - ) : showHomePage || !activeTabUid ? ( - ) : ( <> diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js index 77aa687a1..b03fb8e51 100644 --- a/packages/bruno-app/src/providers/App/useIpcEvents.js +++ b/packages/bruno-app/src/providers/App/useIpcEvents.js @@ -6,9 +6,6 @@ import { import { addTab } from 'providers/ReduxStore/slices/tabs'; -import { - setActiveWorkspaceTab -} from 'providers/ReduxStore/slices/workspaceTabs'; import { brunoConfigUpdateEvent, collectionAddDirectoryEvent, @@ -28,7 +25,10 @@ import { setDotEnvVariables } from 'providers/ReduxStore/slices/collections'; import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions'; -import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions'; +import { + workspaceOpenedEvent, + workspaceConfigUpdatedEvent +} from 'providers/ReduxStore/slices/workspaces/actions'; import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'providers/ReduxStore/slices/workspaces'; import toast from 'react-hot-toast'; import { useDispatch, useStore } from 'react-redux'; @@ -274,24 +274,21 @@ const useIpcEvents = () => { const removeShowPreferencesListener = ipcRenderer.on('main:open-preferences', () => { const state = store.getState(); const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid; - const { showHomePage, showManageWorkspacePage, showApiSpecPage } = state.app; + const workspaces = state.workspaces?.workspaces; const tabs = state.tabs?.tabs; const activeTabUid = state.tabs?.activeTabUid; const activeTab = tabs?.find((t) => t.uid === activeTabUid); - if (showHomePage || showManageWorkspacePage || showApiSpecPage || !activeTabUid) { - if (activeWorkspaceUid) { - dispatch(setActiveWorkspaceTab({ workspaceUid: activeWorkspaceUid, type: 'preferences' })); - } - } else { - dispatch( - addTab({ - type: 'preferences', - uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences', - collectionUid: activeTab?.collectionUid - }) - ); - } + const activeWorkspace = workspaces?.find((w) => w.uid === activeWorkspaceUid); + const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid; + + dispatch( + addTab({ + type: 'preferences', + uid: collectionUid ? `${collectionUid}-preferences` : 'preferences', + collectionUid + }) + ); }); const removePreferencesUpdatesListener = ipcRenderer.on('main:load-preferences', (val) => { diff --git a/packages/bruno-app/src/providers/Hotkeys/index.js b/packages/bruno-app/src/providers/Hotkeys/index.js index 4f46872a1..a87b67e29 100644 --- a/packages/bruno-app/src/providers/Hotkeys/index.js +++ b/packages/bruno-app/src/providers/Hotkeys/index.js @@ -16,7 +16,6 @@ import { } from 'providers/ReduxStore/slices/collections/actions'; import { findCollectionByUid, findItemInCollection } from 'utils/collections'; import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs'; -import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs'; import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app'; import { getKeyBindingsForActionAllOS } from './keyMappings'; @@ -27,8 +26,6 @@ export const HotkeysProvider = (props) => { const tabs = useSelector((state) => state.tabs.tabs); const collections = useSelector((state) => state.collections.collections); const activeTabUid = useSelector((state) => state.tabs.activeTabUid); - const showHomePage = useSelector((state) => state.app.showHomePage); - const activeWorkspaceTabUid = useSelector((state) => state.workspaceTabs.activeTabUid); const [showNewRequestModal, setShowNewRequestModal] = useState(false); const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false); @@ -175,9 +172,7 @@ export const HotkeysProvider = (props) => { // close tab hotkey useEffect(() => { Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => { - if (showHomePage && activeWorkspaceTabUid) { - dispatch(closeWorkspaceTab({ uid: activeWorkspaceTabUid })); - } else if (activeTabUid) { + if (activeTabUid) { dispatch( closeTabs({ tabUids: [activeTabUid] @@ -191,7 +186,7 @@ export const HotkeysProvider = (props) => { return () => { Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]); }; - }, [activeTabUid, showHomePage, activeWorkspaceTabUid]); + }, [activeTabUid]); // Switch to the previous tab useEffect(() => { diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js index e448c55d7..3e17f6ad7 100644 --- a/packages/bruno-app/src/providers/ReduxStore/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/index.js @@ -4,7 +4,6 @@ import debugMiddleware from './middlewares/debug/middleware'; import appReducer from './slices/app'; import collectionsReducer from './slices/collections'; import tabsReducer from './slices/tabs'; -import workspaceTabsReducer from './slices/workspaceTabs'; import notificationsReducer from './slices/notifications'; import globalEnvironmentsReducer from './slices/global-environments'; import logsReducer from './slices/logs'; @@ -28,7 +27,6 @@ export const store = configureStore({ app: appReducer, collections: collectionsReducer, tabs: tabsReducer, - workspaceTabs: workspaceTabsReducer, notifications: notificationsReducer, globalEnvironments: globalEnvironmentsReducer, logs: logsReducer, 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 f59af7ddc..29ebb38db 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -2439,6 +2439,53 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS }); }; +/** + * Opens a scratch collection and creates it in Redux state. + * This is a simplified version of openCollectionEvent for scratch collections, + * without workspace management, toasts, or sidebar toggles. + * + * @param {string} uid - The unique identifier for the scratch collection + * @param {string} pathname - The filesystem path to the scratch collection + * @param {Object} brunoConfig - The Bruno configuration object for the collection + * @returns {Promise} Resolves when the collection is created, rejects on error + */ +export const openScratchCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, getState) => { + const { ipcRenderer } = window; + + return new Promise((resolve, reject) => { + const state = getState(); + const existingCollection = state.collections.collections.find( + (c) => normalizePath(c.pathname) === normalizePath(pathname) + ); + + if (existingCollection) { + resolve(); + return; + } + + const collection = { + version: '1', + uid, + name: brunoConfig.name, + pathname, + items: [], + runtimeVariables: {}, + brunoConfig + }; + + ipcRenderer + .invoke('renderer:get-collection-security-config', pathname) + .then((securityConfig) => { + collectionSchema + .validate(collection) + .then(() => dispatch(_createCollection({ ...collection, securityConfig }))) + .then(resolve) + .catch(reject); + }) + .catch(reject); + }); +}; + export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, getState) => { const { ipcRenderer } = window; @@ -2447,24 +2494,20 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid); const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables || {}; - // Check if collection already exists in Redux state const existingCollection = state.collections.collections.find( (c) => normalizePath(c.pathname) === normalizePath(pathname) ); - // Check if collection is already in the current workspace const isAlreadyInWorkspace = activeWorkspace?.collections?.some( (c) => normalizePath(c.path) === normalizePath(pathname) ); - // If collection already exists in Redux AND in current workspace, show toast and return if (existingCollection && isAlreadyInWorkspace) { toast.success('Collection is already opened'); resolve(); return; } - // If collection exists in Redux but not in workspace, add to workspace if (existingCollection) { if (state.app.sidebarCollapsed) { dispatch(toggleSidebarCollapse()); @@ -2493,7 +2536,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge return; } - // Collection doesn't exist - create it const collection = { version: '1', uid: uid, @@ -2520,7 +2562,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge ); if (currentWorkspace) { - // Set collection-workspace mapping for workspace env vars ipcRenderer.invoke('renderer:set-collection-workspace', uid, currentWorkspace.pathname); const alreadyInWorkspace = currentWorkspace.collections?.some( diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 0e4ea28dc..5d5932285 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -26,7 +26,9 @@ export const tabsSlice = createSlice({ 'collection-runner', 'environment-settings', 'global-environment-settings', - 'preferences' + 'preferences', + 'workspaceOverview', + 'workspaceEnvironments' ]; const existingTab = find(state.tabs, (tab) => tab.uid === uid); @@ -173,8 +175,10 @@ export const tabsSlice = createSlice({ const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid); const tabUids = action.payload.tabUids || []; - // remove the tabs from the state - state.tabs = filter(state.tabs, (t) => !tabUids.includes(t.uid)); + const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments']; + state.tabs = filter(state.tabs, (t) => + !tabUids.includes(t.uid) || nonClosableTypes.includes(t.type) + ); if (activeTab && state.tabs.length) { const { collectionUid } = activeTab; @@ -201,9 +205,14 @@ export const tabsSlice = createSlice({ } }, closeAllCollectionTabs: (state, action) => { - const collectionUid = action.payload.collectionUid; + const { collectionUid } = action.payload; + const prevActiveTabUid = state.activeTabUid; state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid); - state.activeTabUid = null; + + const activeTabStillExists = state.tabs.some((t) => t.uid === prevActiveTabUid); + if (!activeTabStillExists) { + state.activeTabUid = state.tabs.length > 0 ? last(state.tabs).uid : null; + } }, makeTabPermanent: (state, action) => { const { uid } = action.payload; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js deleted file mode 100644 index d4d9e4e6d..000000000 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js +++ /dev/null @@ -1,199 +0,0 @@ -import { createSlice } from '@reduxjs/toolkit'; -import filter from 'lodash/filter'; -import find from 'lodash/find'; -import last from 'lodash/last'; - -const initialState = { - tabs: [], - activeTabUid: null -}; - -export const workspaceTabsSlice = createSlice({ - name: 'workspaceTabs', - initialState, - reducers: { - addWorkspaceTab: (state, action) => { - const { uid, workspaceUid, type, label, permanent = false } = action.payload; - - const existingTab = find(state.tabs, (tab) => tab.uid === uid); - if (existingTab) { - state.activeTabUid = existingTab.uid; - return; - } - - // Check if a tab of the same type already exists for this workspace - const existingTypeTab = find( - state.tabs, - (tab) => tab.workspaceUid === workspaceUid && tab.type === type - ); - if (existingTypeTab) { - state.activeTabUid = existingTypeTab.uid; - return; - } - - state.tabs.push({ - uid, - workspaceUid, - type, - label, - permanent - }); - state.activeTabUid = uid; - }, - focusWorkspaceTab: (state, action) => { - state.activeTabUid = action.payload.uid; - }, - closeWorkspaceTab: (state, action) => { - const tabUid = action.payload.uid; - const tab = find(state.tabs, (t) => t.uid === tabUid); - - // Don't allow closing permanent tabs - if (tab?.permanent) { - return; - } - - state.tabs = filter(state.tabs, (t) => t.uid !== tabUid); - - // If we closed the active tab, activate another one - if (state.activeTabUid === tabUid && state.tabs.length > 0) { - state.activeTabUid = last(state.tabs).uid; - } else if (state.tabs.length === 0) { - state.activeTabUid = null; - } - }, - closeWorkspaceTabs: (state, action) => { - const tabUids = action.payload.tabUids || []; - - // Filter out permanent tabs from the close request - const tabsToClose = tabUids.filter((uid) => { - const tab = find(state.tabs, (t) => t.uid === uid); - return tab && !tab.permanent; - }); - - state.tabs = filter(state.tabs, (t) => !tabsToClose.includes(t.uid)); - - // If active tab was closed, activate another one - if (tabsToClose.includes(state.activeTabUid)) { - if (state.tabs.length > 0) { - state.activeTabUid = last(state.tabs).uid; - } else { - state.activeTabUid = null; - } - } - }, - closeAllWorkspaceTabs: (state, action) => { - const workspaceUid = action.payload?.workspaceUid; - - if (workspaceUid) { - // Close non-permanent tabs for specific workspace - state.tabs = filter( - state.tabs, - (t) => t.workspaceUid !== workspaceUid || t.permanent - ); - } else { - // Close all non-permanent tabs - state.tabs = filter(state.tabs, (t) => t.permanent); - } - - // If active tab was closed, activate another one - const activeTabExists = find(state.tabs, (t) => t.uid === state.activeTabUid); - if (!activeTabExists) { - state.activeTabUid = state.tabs.length > 0 ? last(state.tabs).uid : null; - } - }, - reorderWorkspaceTabs: (state, action) => { - const { sourceUid, targetUid } = action.payload; - const tabs = state.tabs; - - const sourceIdx = tabs.findIndex((t) => t.uid === sourceUid); - const targetIdx = tabs.findIndex((t) => t.uid === targetUid); - - // Don't reorder permanent tabs - const sourceTab = tabs[sourceIdx]; - if (sourceTab?.permanent) { - return; - } - - if (sourceIdx < 0 || targetIdx < 0 || sourceIdx === targetIdx) { - return; - } - - const [moved] = tabs.splice(sourceIdx, 1); - tabs.splice(targetIdx, 0, moved); - - state.tabs = tabs; - }, - initializeWorkspaceTabs: (state, action) => { - const { workspaceUid, permanentTabs } = action.payload; - - // Check if permanent tabs already exist for this workspace - const existingPermanentTabs = state.tabs.filter( - (t) => t.workspaceUid === workspaceUid && t.permanent - ); - - if (existingPermanentTabs.length === 0) { - // Add permanent tabs - permanentTabs.forEach((tab) => { - state.tabs.push({ - uid: `${workspaceUid}-${tab.type}`, - workspaceUid, - type: tab.type, - label: tab.label, - permanent: true - }); - }); - } - - const workspaceActiveTab = state.tabs.find( - (t) => t.uid === state.activeTabUid && t.workspaceUid === workspaceUid - ); - - if (!workspaceActiveTab) { - const workspaceTabs = state.tabs.filter((t) => t.workspaceUid === workspaceUid); - if (workspaceTabs.length > 0) { - state.activeTabUid = workspaceTabs[0].uid; - } - } - }, - setActiveWorkspaceTab: (state, action) => { - const { workspaceUid, type } = action.payload; - let tab = find( - state.tabs, - (t) => t.workspaceUid === workspaceUid && t.type === type - ); - - if (!tab) { - const newTabUid = `${workspaceUid}-${type}`; - const labels = { - overview: 'Overview', - environments: 'Global Environments', - preferences: 'Preferences' - }; - const newTab = { - uid: newTabUid, - workspaceUid, - type, - label: labels[type] || type, - permanent: false - }; - state.tabs.push(newTab); - tab = newTab; - } - - state.activeTabUid = tab.uid; - } - } -}); - -export const { - addWorkspaceTab, - focusWorkspaceTab, - closeWorkspaceTab, - closeWorkspaceTabs, - closeAllWorkspaceTabs, - reorderWorkspaceTabs, - initializeWorkspaceTabs, - setActiveWorkspaceTab -} = workspaceTabsSlice.actions; - -export default workspaceTabsSlice.reducer; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index 08a255d78..33651d320 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -6,13 +6,14 @@ import { updateWorkspace, addCollectionToWorkspace, removeCollectionFromWorkspace, - updateWorkspaceLoadingState + updateWorkspaceLoadingState, + setWorkspaceScratchCollection } from '../workspaces'; import { showHomePage } from '../app'; -import { createCollection, openCollection, openMultipleCollections } from '../collections/actions'; -import { removeCollection } from '../collections'; +import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions'; +import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections'; import { updateGlobalEnvironments } from '../global-environments'; -import { initializeWorkspaceTabs, setActiveWorkspaceTab } from '../workspaceTabs'; +import { addTab, focusTab } from '../tabs'; import { normalizePath } from 'utils/common/path'; import toast from 'react-hot-toast'; @@ -262,15 +263,29 @@ export const switchWorkspace = (workspaceUid) => { dispatch(updateGlobalEnvironments({ globalEnvironments: [], activeGlobalEnvironmentUid: null })); } + const scratchCollection = await dispatch(mountScratchCollection(workspaceUid)); await loadWorkspaceCollectionsForSwitch(dispatch, workspace); - dispatch(showHomePage()); - const permanentTabs = [ - { type: 'overview', label: 'Overview' }, - { type: 'environments', label: 'Global Environments' } - ]; - dispatch(initializeWorkspaceTabs({ workspaceUid, permanentTabs })); - dispatch(setActiveWorkspaceTab({ workspaceUid, type: 'overview' })); + if (scratchCollection?.uid) { + const overviewTabUid = `${scratchCollection.uid}-overview`; + const environmentsTabUid = `${scratchCollection.uid}-environments`; + + dispatch(addTab({ + uid: overviewTabUid, + collectionUid: scratchCollection.uid, + type: 'workspaceOverview' + })); + + dispatch(addTab({ + uid: environmentsTabUid, + collectionUid: scratchCollection.uid, + type: 'workspaceEnvironments' + })); + + dispatch(focusTab({ + uid: overviewTabUid + })); + } }; }; @@ -840,3 +855,88 @@ export const deleteWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (d .catch(reject); }); }; + +// Scratch Collection Actions + +/** + * Get the scratch collection for a workspace + */ +export const getScratchCollection = (workspaceUid) => { + return (dispatch, getState) => { + const state = getState(); + const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid); + if (!workspace?.scratchCollectionUid) { + return null; + } + return state.collections.collections.find((c) => c.uid === workspace.scratchCollectionUid); + }; +}; + +/** + * Mount scratch collection for a workspace + */ +export const mountScratchCollection = (workspaceUid) => { + return async (dispatch, getState) => { + const state = getState(); + const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid); + + if (!workspace) { + return null; + } + + if (workspace.scratchCollectionUid) { + const existingCollection = state.collections.collections.find( + (c) => c.uid === workspace.scratchCollectionUid + ); + if (existingCollection) { + return existingCollection; + } + } + + try { + const tempDirectoryPath = await ipcRenderer.invoke('renderer:mount-workspace-scratch', { + workspaceUid, + workspacePath: workspace.pathname || 'default' + }); + + const { generateUidBasedOnHash } = await import('utils/common'); + const scratchCollectionUid = generateUidBasedOnHash(tempDirectoryPath); + + const brunoConfig = { + opencollection: '1.0.0', + name: 'Scratch', + type: 'collection', + ignore: ['node_modules', '.git'] + }; + + await ipcRenderer.invoke('renderer:add-collection-watcher', { + collectionPath: tempDirectoryPath, + collectionUid: scratchCollectionUid, + brunoConfig + }); + + await dispatch(openScratchCollectionEvent(scratchCollectionUid, tempDirectoryPath, brunoConfig)); + + dispatch(setWorkspaceScratchCollection({ + workspaceUid, + scratchCollectionUid, + scratchTempDirectory: tempDirectoryPath + })); + + dispatch(addTransientDirectory({ + collectionUid: scratchCollectionUid, + pathname: tempDirectoryPath + })); + + dispatch(updateCollectionMountStatus({ collectionUid: scratchCollectionUid, mountStatus: 'mounted' })); + + return { uid: scratchCollectionUid, pathname: tempDirectoryPath }; + } catch (error) { + console.error('Error mounting scratch collection:', error); + if (workspace.scratchCollectionUid) { + dispatch(updateCollectionMountStatus({ collectionUid: workspace.scratchCollectionUid, mountStatus: 'unmounted' })); + } + return null; + } + }; +}; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js index ec62e47c7..fb26be4b2 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/index.js @@ -116,6 +116,16 @@ export const workspacesSlice = createSlice({ workspace.dotEnvVariables = mainEnvFile?.variables || []; workspace.dotEnvExists = mainEnvFile?.exists || false; } + }, + + // Set scratch collection info on workspace + setWorkspaceScratchCollection: (state, action) => { + const { workspaceUid, scratchCollectionUid, scratchTempDirectory } = action.payload; + const workspace = state.workspaces.find((w) => w.uid === workspaceUid); + if (workspace) { + workspace.scratchCollectionUid = scratchCollectionUid; + workspace.scratchTempDirectory = scratchTempDirectory; + } } } }); @@ -129,7 +139,8 @@ export const { removeCollectionFromWorkspace, updateWorkspaceLoadingState, workspaceDotEnvUpdateEvent, - setWorkspaceDotEnvVariables + setWorkspaceDotEnvVariables, + setWorkspaceScratchCollection } = workspacesSlice.actions; export default workspacesSlice.reducer; diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 7b11d31b7..d3eacb93a 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -1737,3 +1737,14 @@ export const filterTransientItems = (items) => { return item; }); }; + +/** + * Checks if a collection is a scratch collection for any workspace + * @param {Object} collection - The collection to check + * @param {Array} workspaces - Array of workspace objects + * @returns {boolean} True if the collection is a scratch collection + */ +export const isScratchCollection = (collection, workspaces) => { + if (!collection || !workspaces) return false; + return workspaces.some((w) => w.scratchCollectionUid === collection.uid); +}; diff --git a/packages/bruno-electron/src/app/collections.js b/packages/bruno-electron/src/app/collections.js index e82bef6a8..63d35486b 100644 --- a/packages/bruno-electron/src/app/collections.js +++ b/packages/bruno-electron/src/app/collections.js @@ -7,6 +7,14 @@ const { generateUidBasedOnHash } = require('../utils/common'); const { transformBrunoConfigAfterRead } = require('../utils/transformBrunoConfig'); const { parseCollection } = require('@usebruno/filestore'); +// Track scratch collection paths (temp directories for workspace scratch requests) +const scratchCollectionPaths = new Set(); + +// Register a scratch collection path +const registerScratchCollectionPath = (scratchPath) => { + scratchCollectionPaths.add(path.normalize(scratchPath)); +}; + // todo: bruno.json config schema validation errors must be propagated to the UI const configSchema = Yup.object({ name: Yup.string().max(256, 'name must be 256 characters or less').required('name is required'), @@ -170,5 +178,6 @@ const openCollectionsByPathname = async (win, watcher, collectionPaths, options module.exports = { openCollection, openCollectionDialog, - openCollectionsByPathname + openCollectionsByPathname, + registerScratchCollectionPath }; diff --git a/packages/bruno-electron/src/ipc/collection.js b/packages/bruno-electron/src/ipc/collection.js index 1901e6762..132148cbe 100644 --- a/packages/bruno-electron/src/ipc/collection.js +++ b/packages/bruno-electron/src/ipc/collection.js @@ -55,7 +55,7 @@ const { isBruEnvironmentConfig, isCollectionRootBruFile } = require('../utils/filesystem'); -const { openCollectionDialog, openCollectionsByPathname } = require('../app/collections'); +const { openCollectionDialog, openCollectionsByPathname, registerScratchCollectionPath } = require('../app/collections'); const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common'); const { moveRequestUid, deleteRequestUid, syncExampleUidsCache } = require('../cache/requestUids'); const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies'); @@ -99,6 +99,11 @@ const findCollectionPathByItemPath = (filePath) => { try { const metadataContent = fs.readFileSync(metadataPath, 'utf8'); const metadata = JSON.parse(metadataContent); + + if (metadata.type === 'scratch') { + return transientDirPath; + } + if (metadata.collectionPath) { return metadata.collectionPath; } @@ -387,43 +392,50 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { } }); - // save transient request (handles move from temp to permanent location) - ipcMain.handle('renderer:save-transient-request', async (event, { sourcePathname, targetDirname, targetFilename, request, format }) => { + ipcMain.handle('renderer:save-transient-request', async (event, { sourcePathname, targetDirname, targetFilename, request, format, sourceFormat }) => { try { - // Validate source exists if (!fs.existsSync(sourcePathname)) { throw new Error(`Source path: ${sourcePathname} does not exist`); } - // Validate target directory exists if (!fs.existsSync(targetDirname)) { throw new Error(`Target directory: ${targetDirname} does not exist`); } - // Check if the target directory is inside a collection validatePathIsInsideCollection(targetDirname); - // Use provided target filename or fall back to source filename - const filename = targetFilename || path.basename(sourcePathname); - const targetPathname = path.join(targetDirname, filename); + const collectionPath = findCollectionPathByItemPath(targetDirname); + if (!collectionPath) { + throw new Error('Could not determine collection for target directory'); + } + const targetFormat = getCollectionFormat(collectionPath); + + const filename = targetFilename || path.basename(sourcePathname); + const filenameWithoutExt = filename.replace(/\.(bru|yml)$/, ''); + const finalFilename = `${filenameWithoutExt}.${targetFormat}`; + const targetPathname = path.join(targetDirname, finalFilename); - // Check for filename conflicts and throw error if exists if (fs.existsSync(targetPathname)) { - throw new Error(`A file with the name "${filename}" already exists in the target location`); + throw new Error(`A file with the name "${finalFilename}" already exists in the target location`); } - // Step 1: Save the updated content to the transient file - syncExampleUidsCache(sourcePathname, request.examples); - const content = await stringifyRequestViaWorker(request, { format }); - await writeFile(sourcePathname, content); + const actualSourceFormat = sourceFormat || 'yml'; + const needsConversion = actualSourceFormat !== targetFormat; - // Step 2: Read the file content from temp (this is the actual file content) - const fileContent = await fs.promises.readFile(sourcePathname, 'utf8'); + let finalContent; + if (needsConversion) { + const { parseRequest, stringifyRequest } = require('@usebruno/filestore'); + const sourceContent = await fs.promises.readFile(sourcePathname, 'utf8'); + const parsedRequest = parseRequest(sourceContent, { format: actualSourceFormat }); + const mergedRequest = { ...parsedRequest, ...request }; + syncExampleUidsCache(sourcePathname, mergedRequest.examples); + finalContent = stringifyRequest(mergedRequest, { format: targetFormat }); + } else { + syncExampleUidsCache(sourcePathname, request.examples); + finalContent = await stringifyRequestViaWorker(request, { format: targetFormat }); + } - // Step 3: Create new file at target location with the content - await writeFile(targetPathname, fileContent); - - // Return the new pathname (file watcher will handle adding to Redux) + await writeFile(targetPathname, finalContent); return { newPathname: targetPathname }; } catch (error) { return Promise.reject(error); @@ -1860,6 +1872,105 @@ const registerRendererEventHandlers = (mainWindow, watcher) => { return tempDirectoryPath; }); + ipcMain.handle('renderer:mount-workspace-scratch', async (event, { workspaceUid, workspacePath }) => { + try { + const tempDirectoryPath = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-scratch-')); + registerScratchCollectionPath(tempDirectoryPath); + + const collectionRoot = { + meta: { + name: 'Scratch' + } + }; + + const brunoConfig = { + opencollection: '1.0.0', + name: 'Scratch', + type: 'collection', + ignore: ['node_modules', '.git'] + }; + + const content = stringifyCollection(collectionRoot, brunoConfig, { format: 'yml' }); + await writeFile(path.join(tempDirectoryPath, 'opencollection.yml'), content); + + const metadata = { + workspaceUid, + workspacePath, + type: 'scratch' + }; + fs.writeFileSync(path.join(tempDirectoryPath, 'metadata.json'), JSON.stringify(metadata)); + + return tempDirectoryPath; + } catch (error) { + console.error('Error mounting workspace scratch collection:', error); + throw error; + } + }); + + ipcMain.handle('renderer:add-collection-watcher', async (event, { collectionPath, collectionUid, brunoConfig }) => { + if (!watcher || !mainWindow) { + throw new Error('Watcher or mainWindow not available'); + } + + try { + const { size, filesCount, maxFileSize } = await getCollectionStats(collectionPath); + + const shouldLoadCollectionAsync + = (size > MAX_COLLECTION_SIZE_IN_MB) + || (filesCount > MAX_COLLECTION_FILES_COUNT) + || (maxFileSize > MAX_SINGLE_FILE_SIZE_IN_COLLECTION_IN_MB); + + watcher.addWatcher(mainWindow, collectionPath, collectionUid, brunoConfig, false, shouldLoadCollectionAsync); + + return { success: true }; + } catch (error) { + console.error('Error adding collection watcher:', error); + throw error; + } + }); + + ipcMain.handle('renderer:save-scratch-request', async (event, { sourcePathname, targetDirname, targetFilename, request }) => { + try { + if (!fs.existsSync(sourcePathname)) { + throw new Error(`Source path: ${sourcePathname} does not exist`); + } + + if (!fs.existsSync(targetDirname)) { + throw new Error(`Target directory: ${targetDirname} does not exist`); + } + + validatePathIsInsideCollection(targetDirname); + + const collectionPath = findCollectionPathByItemPath(targetDirname); + if (!collectionPath) { + throw new Error('Could not determine collection for target directory'); + } + const format = getCollectionFormat(collectionPath); + + const filename = targetFilename || path.basename(sourcePathname); + const filenameWithoutExt = filename.replace(/\.(bru|yml)$/, ''); + const finalFilename = `${filenameWithoutExt}.${format}`; + const targetPathname = path.join(targetDirname, finalFilename); + + if (fs.existsSync(targetPathname)) { + throw new Error(`A file with the name "${finalFilename}" already exists in the target location`); + } + + const content = await stringifyRequestViaWorker(request, { format }); + + await writeFile(targetPathname, content); + + if (request.examples) { + syncExampleUidsCache(collectionPath, request.examples); + } + + return { newPathname: targetPathname }; + } catch (error) { + console.error('Error saving scratch request:', error); + return Promise.reject(error); + } + }); + ipcMain.handle('renderer:show-in-folder', async (event, filePath) => { try { if (!filePath) { diff --git a/tests/scratch-requests/scratch-requests.spec.ts b/tests/scratch-requests/scratch-requests.spec.ts new file mode 100644 index 000000000..232a573ca --- /dev/null +++ b/tests/scratch-requests/scratch-requests.spec.ts @@ -0,0 +1,238 @@ +import { test, expect, Page } from '../../playwright'; +import { fillRequestUrl, sendRequest, clickResponseAction, createCollection, closeAllCollections, closeAllTabs } from '../utils/page'; +import { buildCommonLocators } from '../utils/page/locators'; + +test.describe.serial('Scratch Requests', () => { + let locators: ReturnType; + + test.beforeAll(async ({ page }) => { + locators = buildCommonLocators(page); + + // Wait for the app to fully load + await page.locator('[data-app-state="loaded"]').waitFor({ timeout: 30000 }); + }); + + test.afterAll(async ({ page }) => { + // Close all tabs (including scratch requests) to avoid "unsaved changes" modal + await closeAllTabs(page); + + // Clean up any regular collections + await closeAllCollections(page); + }); + + /** + * Helper to create a scratch request when on workspace overview + */ + const createScratchRequest = async (page: Page, requestType: 'HTTP' | 'GraphQL' | 'gRPC' | 'WebSocket' = 'HTTP') => { + await test.step(`Create scratch ${requestType} request`, async () => { + // Click the + button to create a new request (this is on the workspace overview) + const createButton = page.getByRole('button', { name: 'New Transient Request' }); + await createButton.waitFor({ state: 'visible', timeout: 5000 }); + + // Right-click to open the dropdown menu + await createButton.click({ button: 'right' }); + + // Wait for dropdown to be visible + await page.locator('.dropdown-item').first().waitFor({ state: 'visible' }); + + // Select the request type from dropdown + await page.locator('.dropdown-item').filter({ hasText: requestType }).click(); + + // Wait for the request tab to be active + await page.locator('.request-tab.active').waitFor({ state: 'visible' }); + await expect(page.locator('.request-tab.active')).toContainText('Untitled'); + await page.waitForTimeout(300); + }); + }; + + /** + * Helper to navigate to workspace overview (home) + */ + const goToWorkspaceOverview = async (page: Page) => { + await test.step('Navigate to workspace overview', async () => { + // Click the home icon in the title bar to go to workspace overview + const homeButton = page.locator('.titlebar-left .home-button'); + await homeButton.click(); + await page.waitForTimeout(300); + }); + }; + + test('Create scratch HTTP request - should open in workspace tabs', async ({ page }) => { + await test.step('Navigate to workspace overview', async () => { + await goToWorkspaceOverview(page); + }); + + await test.step('Create scratch HTTP request', async () => { + await createScratchRequest(page, 'HTTP'); + await fillRequestUrl(page, 'http://localhost:8081/ping'); + }); + + await test.step('Verify HTTP request tab is open', async () => { + const activeTab = page.locator('.request-tab.active'); + await expect(activeTab).toBeVisible(); + await expect(activeTab).toContainText('Untitled'); + }); + + await test.step('Verify collection header shows for scratch collection', async () => { + // Scratch requests should show the collection header with workspace name in the switcher + const collectionSwitcher = page.locator('.collection-switcher'); + await expect(collectionSwitcher).toBeVisible(); + + // The switcher should display the workspace name (e.g., "My Workspace") + const switcherName = page.locator('.switcher-name'); + await expect(switcherName).toBeVisible(); + }); + }); + + test('Create scratch GraphQL request', async ({ page }) => { + await test.step('Navigate to workspace overview', async () => { + await goToWorkspaceOverview(page); + }); + + await test.step('Create scratch GraphQL request', async () => { + await createScratchRequest(page, 'GraphQL'); + await fillRequestUrl(page, 'https://api.example.com/graphql'); + }); + + await test.step('Verify GraphQL request tab is open', async () => { + const activeTab = page.locator('.request-tab.active'); + await expect(activeTab).toBeVisible(); + await expect(activeTab).toContainText('Untitled'); + }); + }); + + test('Create scratch gRPC request', async ({ page }) => { + await test.step('Navigate to workspace overview', async () => { + await goToWorkspaceOverview(page); + }); + + await test.step('Create scratch gRPC request', async () => { + await createScratchRequest(page, 'gRPC'); + await fillRequestUrl(page, 'grpc://localhost:50051'); + }); + + await test.step('Verify gRPC request tab is open', async () => { + const activeTab = page.locator('.request-tab.active'); + await expect(activeTab).toBeVisible(); + await expect(activeTab).toContainText('Untitled'); + }); + }); + + test('Create scratch WebSocket request', async ({ page }) => { + await test.step('Navigate to workspace overview', async () => { + await goToWorkspaceOverview(page); + }); + + await test.step('Create scratch WebSocket request', async () => { + await createScratchRequest(page, 'WebSocket'); + await fillRequestUrl(page, 'ws://localhost:8082'); + }); + + await test.step('Verify WebSocket request tab is open', async () => { + const activeTab = page.locator('.request-tab.active'); + await expect(activeTab).toBeVisible(); + await expect(activeTab).toContainText('Untitled'); + }); + }); + + test('Send scratch HTTP request - verify response', async ({ page }) => { + await test.step('Navigate to workspace overview', async () => { + await goToWorkspaceOverview(page); + }); + + await test.step('Create scratch HTTP request', async () => { + await createScratchRequest(page, 'HTTP'); + await fillRequestUrl(page, 'http://localhost:8081/ping'); + }); + + await test.step('Send request and verify response', async () => { + await sendRequest(page, 200); + + // Copy response to clipboard and verify + await clickResponseAction(page, 'response-copy-btn'); + await expect(page.getByText('Response copied to clipboard')).toBeVisible(); + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()); + expect(clipboardText).toBe('pong'); + }); + }); + + test('Save scratch request to a collection', async ({ page, createTmpDir }) => { + // Create a collection to save the scratch request to + const collectionPath = await createTmpDir('scratch-save-target'); + await createCollection(page, 'scratch-save-test', collectionPath); + + await test.step('Navigate to workspace overview', async () => { + await goToWorkspaceOverview(page); + }); + + await test.step('Create scratch HTTP request', async () => { + await createScratchRequest(page, 'HTTP'); + await fillRequestUrl(page, 'http://localhost:8081/echo'); + }); + + await test.step('Trigger save action using keyboard shortcut', async () => { + const saveShortcut = process.platform === 'darwin' ? 'Meta+s' : 'Control+s'; + await page.keyboard.press(saveShortcut); + }); + + await test.step('Fill in save dialog', async () => { + // Wait for save modal to appear - scratch requests show "Select Collection" first + const saveModal = page.locator('.bruno-modal-card'); + await expect(saveModal).toBeVisible({ timeout: 5000 }); + + // Fill in request name + const requestNameInput = saveModal.locator('#request-name'); + await requestNameInput.clear(); + await requestNameInput.fill('Saved Scratch Request'); + + // Select the target collection from the list (this transitions from "Select Collection" to "Save Request") + const collectionSelector = saveModal.locator('.collection-item').filter({ hasText: 'scratch-save-test' }); + await collectionSelector.click(); + + // Wait for the modal to transition to "Save Request" state (Save button becomes visible) + const saveButton = saveModal.getByRole('button', { name: 'Save' }); + await expect(saveButton).toBeVisible({ timeout: 5000 }); + + // Click Save button + await saveButton.click(); + + // Wait for success toast + await expect(page.getByText('Request saved')).toBeVisible({ timeout: 5000 }); + }); + + await test.step('Verify saved request appears in collection sidebar', async () => { + // Click on the collection to ensure it's expanded + await locators.sidebar.collection('scratch-save-test').click(); + + // Look for the saved request in sidebar + const savedRequest = locators.sidebar.request('Saved Scratch Request'); + await expect(savedRequest).toBeVisible(); + }); + }); + + test('Multiple scratch requests maintain separate tabs', async ({ page }) => { + await test.step('Navigate to workspace overview', async () => { + await goToWorkspaceOverview(page); + }); + + await test.step('Create first scratch HTTP request', async () => { + await createScratchRequest(page, 'HTTP'); + await fillRequestUrl(page, 'http://localhost:8081/ping'); + }); + + await test.step('Create second scratch HTTP request', async () => { + await createScratchRequest(page, 'HTTP'); + await fillRequestUrl(page, 'http://localhost:8081/echo'); + }); + + await test.step('Verify both tabs exist', async () => { + const tabs = page.locator('.request-tab'); + const tabCount = await tabs.count(); + expect(tabCount).toBeGreaterThanOrEqual(2); + + // Both should contain "Untitled" with different numbers + await expect(tabs.filter({ hasText: 'Untitled' }).first()).toBeVisible(); + }); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 36798ccf8..b7b261f4a 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -958,6 +958,37 @@ const saveRequest = async (page: Page) => { }); }; +/** + * Close all open request tabs using the right-click context menu + * @param page - The page object + * @returns void + */ +const closeAllTabs = async (page: Page) => { + await test.step('Close all tabs', async () => { + // Find actual request tabs (those with .tab-method, not Overview/Environments) + const requestTabLabel = page.locator('.request-tab').filter({ has: page.locator('.tab-method') }).locator('.tab-label').first(); + if (!(await requestTabLabel.isVisible().catch(() => false))) { + return; // No request tabs to close + } + + // Right-click on the tab label to open context menu + await requestTabLabel.click({ button: 'right' }); + + // Wait for the dropdown menu to appear + const dropdown = page.locator('.tippy-box.dropdown'); + await dropdown.waitFor({ state: 'visible', timeout: 5000 }); + + // Click "Close All" menu item + await dropdown.locator('[role="menuitem"][data-item-id="close-all"]').click(); + + // Handle "Unsaved Transient Requests" modal if it appears + const discardAllButton = page.getByRole('button', { name: 'Discard All' }); + if (await discardAllButton.isVisible({ timeout: 1000 }).catch(() => false)) { + await discardAllButton.click(); + } + }); +}; + export { closeAllCollections, openCollection, @@ -991,7 +1022,8 @@ export { addAssertion, editAssertion, deleteAssertion, - saveRequest + saveRequest, + closeAllTabs }; export type { SandboxMode, EnvironmentType, EnvironmentVariable, ImportCollectionOptions, CreateRequestOptions, CreateUntitledRequestOptions, CreateTransientRequestOptions, AssertionInput };