From 53e158c6d198646aa777086f66f65e2c5f9caa1f Mon Sep 17 00:00:00 2001 From: Chirag Chandrashekhar Date: Fri, 13 Feb 2026 15:34:47 +0530 Subject: [PATCH] Feature/scratch requests (#7087) * feat: implement workspace-level scratch requests Add support for temporary "scratch" requests at the workspace level that are not tied to any collection. These requests are stored in a temp directory and displayed as tabs in the workspace home. Key changes: - Add IPC handlers for mounting scratch directory and creating requests - Add scratch directory watcher in collection-watcher.js - Extend workspaces Redux slice with scratch state and reducers - Add IPC listeners for scratch request events - Create ScratchRequestPane and CreateScratchRequest components - Update WorkspaceTabs with "+" button for creating scratch requests - Update WorkspaceHome to render scratch request tabs - Filter scratch collections from sidebar display Supports all request types: HTTP, GraphQL, gRPC, and WebSocket. * style: improve create scratch request button styling - Use Button component with ghost variant and primary color - Position button inside scrollable tab area - Vertically center button with tabs - Clean up unnecessary CSS properties * fix: append scratch request dropdown to body to fix z-index issue * refactor: improve scratch collection detection with path registration - Add centralized scratch path tracking in backend (scratchCollectionPaths Set) - Register scratch paths when created via renderer:mount-workspace-scratch - Set brunoConfig.type='scratch' based on registered paths instead of string pattern - Store scratchTempDirectory path in workspace state for frontend validation - Update schema to accept 'scratch' as valid collection type - Simplify frontend filtering to use brunoConfig.type or path comparison - Remove fragile 'bruno-scratch-' string pattern matching - Prevent scratch collections from being added to workspace.collections * refactor: use CreateTransientRequest for scratch requests in workspace tabs - Remove CreateScratchRequest component in favor of reusing CreateTransientRequest - Register scratch collection temp directory via addTransientDirectory for transient request creation - Add scratch collection item sync with workspace tabs - Display HTTP method with color on scratch request tabs * refactor: unify WorkspaceTabs with RequestTabs system Remove separate WorkspaceTabs system and integrate workspace views (Overview, Environments) into the unified RequestTabs architecture using scratch collections. Key changes: - Remove WorkspaceTabs component and Redux slice - Add workspaceOverview and workspaceEnvironments as special tab types - Create WorkspaceHeader component to display workspace name in toolbar - Make workspace tabs non-closable and always present - Update tab creation on workspace switch to automatically add Overview and Environments tabs - Simplify WorkspaceHome component by removing redundant header - Update all references from WorkspaceTabs to unified tab system Benefits: - Single tab system for all content (collections and workspace views) - Consistent UX with unified navigation pattern - Reduced code complexity (~1000+ lines removed) - Easier maintenance and feature development * fix: enable automatic tab creation for scratch collection transient requests - Add updateCollectionMountStatus to properly set scratch collection mount status to 'mounted' - Create new renderer:add-collection-watcher IPC handler for explicit watcher setup - Move workspace tab type checks before collection validation in RequestTabPanel - Update mountScratchCollection to use explicit watcher setup instead of open-multiple-collections This ensures the task middleware recognizes scratch collections as fully mounted, allowing transient requests to automatically open in tabs when created. * feat: add collection selector with breadcrumb navigation for scratch requests Add multi-step save flow for scratch collection requests with collection selection before folder selection. Includes breadcrumb navigation showing "Collections > [Selected Collection] > [Folders...]" that allows users to navigate back to collection selector. Refactor scratch collection detection to use workspace.scratchCollectionUid instead of persisting type to brunoConfig, providing cleaner separation of concerns without disk persistence. Add backend support for automatic format conversion when saving from YAML scratch collections to BRU collections. * chore: remove redundant comments and simplify code * fix: use focusTab for home button, remove unused ScratchRequestPane * fix: improve SaveTransientRequest collection mounting and selection flow * refactor: use WorkspaceOverview directly, remove WorkspaceHome wrapper * feat: add workspace management dropdown with rename, export, and close options * refactor: extract CollectionListItem component with Redux selector for mount status * refactor: separate scratch collection handling from openCollectionEvent - Create dedicated openScratchCollectionEvent function for scratch collections - Revert openCollectionEvent to clean state without scratch-specific logic - Simplify closeTabs and closeAllCollectionTabs reducers in tabs slice - Remove unused isScratchCollectionPath helper function * test: add scratch requests test suite - Add tests for creating scratch requests (HTTP, GraphQL, gRPC, WebSocket) - Add tests for sending scratch requests and verifying response - Add tests for saving scratch requests to a collection - Add tests for multiple tabs and closing tabs - Handle "Don't Save" modal in playwright fixture during app close * refactor: address code review feedback for scratch requests feature - Fix RequestTabPanel hooks violation by moving SSR guard after hooks - Fix validateWorkspaceName to trim before length check - Use stable deterministic UID in SaveTransientRequest - Use ES6 shorthand for collectionUid in useIpcEvents - Add JSDoc and error handling to openScratchCollectionEvent - Fix closeAllCollectionTabs to preserve activeTabUid when not removed - Add syncExampleUidsCache call to save-scratch-request handler - Use getCollectionFormat for save-transient-request extension handling - Fix Playwright modal handling with proper waitFor pattern - Make keyboard shortcut platform-aware in scratch tests - Remove flaky close tab test, handled by fixture cleanup - Extract isScratchCollection utility to reduce duplication - Memoize scratch collection derivation in RequestTabs - Use theme color instead of Tailwind class for success icon - Wrap resetForm and handleCancelWorkspaceRename in useCallback - Extract FolderBreadcrumbs into separate component - Call reset() inside useEffect in useCollectionFolderTree hook - Defer workspace scratch state updates until mount succeeds * feat: add unified collection header with context switcher dropdown - Create CollectionHeader component that replaces WorkspaceHeader and CollectionToolBar - Add dropdown to switch between workspace and mounted collections - Display tab count badge next to collection/workspace name in header and dropdown - Remove unused WorkspaceHeader and CollectionToolBar components - Handle null/undefined values elegantly throughout * chore: allow pr to comment * refactor: improve scratch requests test cleanup with closeAllTabs helper - Revert playwright/index.ts modal handling hack - Add closeAllTabs helper to test utils for proper tab cleanup - Update scratch-requests test to use closeAllTabs in afterAll - Fix test assertion for new collection header dropdown structure --------- Co-authored-by: Chirag Chandrashekhar --- .github/workflows/flaky-test-detector.yml | 1 + .../src/components/AppTitleBar/index.js | 8 +- .../src/components/Dropdown/StyledWrapper.js | 11 + .../src/components/RequestTabPanel/index.js | 19 +- .../CollectionHeader/StyledWrapper.js | 125 +++++ .../RequestTabs/CollectionHeader/index.js | 452 +++++++++++++++ .../CollectionToolBar/StyledWrapper.js | 5 - .../RequestTabs/CollectionToolBar/index.js | 83 --- .../RequestTabs/RequestTab/SpecialTab.js | 20 +- .../RequestTabs/RequestTab/index.js | 6 +- .../src/components/RequestTabs/index.js | 16 +- .../CollectionListItem/index.js | 43 ++ .../FolderBreadcrumbs/index.js | 38 ++ .../SaveTransientRequest/StyledWrapper.js | 73 +++ .../components/SaveTransientRequest/index.js | 522 +++++++++++------- .../components/Sidebar/Collections/index.js | 16 +- .../Sections/CollectionsSection/index.js | 13 +- .../src/components/StatusBar/index.js | 26 +- .../components/WorkspaceHome/StyledWrapper.js | 110 ---- .../CollectionsList/index.js | 11 +- .../src/components/WorkspaceHome/index.js | 262 --------- .../components/WorkspaceTabs/StyledWrapper.js | 197 ------- .../WorkspaceTab/StyledWrapper.js | 61 -- .../WorkspaceTabs/WorkspaceTab/index.js | 45 -- .../src/components/WorkspaceTabs/index.js | 158 ------ .../hooks/useCollectionFolderTree/index.js | 7 +- packages/bruno-app/src/pages/Bruno/index.js | 4 - .../src/providers/App/useIpcEvents.js | 33 +- .../bruno-app/src/providers/Hotkeys/index.js | 9 +- .../src/providers/ReduxStore/index.js | 2 - .../ReduxStore/slices/collections/actions.js | 53 +- .../src/providers/ReduxStore/slices/tabs.js | 19 +- .../ReduxStore/slices/workspaceTabs.js | 199 ------- .../ReduxStore/slices/workspaces/actions.js | 122 +++- .../ReduxStore/slices/workspaces/index.js | 13 +- .../bruno-app/src/utils/collections/index.js | 11 + .../bruno-electron/src/app/collections.js | 11 +- packages/bruno-electron/src/ipc/collection.js | 153 ++++- .../scratch-requests/scratch-requests.spec.ts | 238 ++++++++ tests/utils/page/actions.ts | 34 +- 40 files changed, 1788 insertions(+), 1441 deletions(-) create mode 100644 packages/bruno-app/src/components/RequestTabs/CollectionHeader/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js delete mode 100644 packages/bruno-app/src/components/RequestTabs/CollectionToolBar/StyledWrapper.js delete mode 100644 packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js create mode 100644 packages/bruno-app/src/components/SaveTransientRequest/CollectionListItem/index.js create mode 100644 packages/bruno-app/src/components/SaveTransientRequest/FolderBreadcrumbs/index.js delete mode 100644 packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js delete mode 100644 packages/bruno-app/src/components/WorkspaceHome/index.js delete mode 100644 packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js delete mode 100644 packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js delete mode 100644 packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js delete mode 100644 packages/bruno-app/src/components/WorkspaceTabs/index.js delete mode 100644 packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js create mode 100644 tests/scratch-requests/scratch-requests.spec.ts 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 };