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 <cchirag85@gmail.com>
This commit is contained in:
Chirag Chandrashekhar
2026-02-13 15:34:47 +05:30
committed by GitHub
parent 3e581675cd
commit 53e158c6d1
40 changed files with 1788 additions and 1441 deletions

View File

@@ -9,6 +9,7 @@ on:
permissions:
contents: read
pull-requests: write
issues: write
checks: write
jobs:

View File

@@ -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) => {

View File

@@ -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};
}

View File

@@ -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 <div></div>;
}
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 <div></div>;
}
if (!activeTabUid || !focusedTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
@@ -183,6 +186,14 @@ const RequestTabPanel = () => {
return <Preferences />;
}
if (focusedTab.type === 'workspaceOverview') {
return activeWorkspace ? <WorkspaceOverview workspace={activeWorkspace} /> : null;
}
if (focusedTab.type === 'workspaceEnvironments') {
return <GlobalEnvironmentSettings />;
}
if (!focusedTab.uid || !focusedTab.collectionUid) {
return <div className="pb-4 px-4">An error occurred!</div>;
}

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper>
{closeWorkspaceModalOpen && currentWorkspace?.uid && (
<CloseWorkspace
workspaceUid={currentWorkspace.uid}
onClose={() => setCloseWorkspaceModalOpen(false)}
/>
)}
<div className="flex items-center justify-between gap-2 py-2 px-4">
{/* Left side: Switcher dropdown or rename input */}
<div className="collection-switcher">
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<DisplayIcon size={18} strokeWidth={1.5} />
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
{workspaceNameError && (
<span className="workspace-error">{workspaceNameError}</span>
)}
</div>
) : (
<Dropdown
placement="bottom-start"
onCreate={onSwitcherCreate}
appendTo={() => document.body}
icon={(
<button className="switcher-trigger">
<DisplayIcon size={18} strokeWidth={1.5} />
<span className="switcher-name">{displayName}</span>
{tabCount > 0 && <span className="tab-count">{tabCount}</span>}
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
</button>
)}
>
{/* Workspace section */}
{currentWorkspace && (
<>
<div className="label-item">Workspace</div>
<div
className={classNames('dropdown-item', {
'dropdown-item-active': isScratchCollection
})}
onClick={() => handleSwitchToWorkspace(currentWorkspace.uid)}
>
<div className="dropdown-icon">
<IconCategory size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">
{currentWorkspace.name || 'Untitled Workspace'}
</span>
{workspaceTabCount > 0 && (
<span className="dropdown-tab-count">{workspaceTabCount}</span>
)}
</div>
</>
)}
{/* Collections section */}
{mountedCollections.length > 0 && (
<>
<div className="dropdown-separator" />
<div className="label-item">Collections</div>
{mountedCollections.map((col) => {
const colTabCount = getTabCount(col.uid);
return (
<div
key={col.uid}
className={classNames('dropdown-item', {
'dropdown-item-active': !isScratchCollection && collection.uid === col.uid
})}
onClick={() => handleSwitchToCollection(col)}
>
<div className="dropdown-icon">
<IconBox size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">{col.name || 'Untitled Collection'}</span>
{colTabCount > 0 && (
<span className="dropdown-tab-count">{colTabCount}</span>
)}
</div>
);
})}
</>
)}
</Dropdown>
)}
{/* Workspace actions dropdown */}
{showWorkspaceActions && (
<Dropdown
placement="bottom-start"
onCreate={onWorkspaceActionsCreate}
appendTo={() => document.body}
icon={<IconDots size={18} strokeWidth={1.5} className="workspace-actions-trigger" />}
>
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
<div className="dropdown-icon">
<IconEdit size={16} strokeWidth={1.5} />
</div>
<span>Rename</span>
</div>
<div className="dropdown-item" onClick={handleShowInFolder}>
<div className="dropdown-icon">
<IconFolder size={16} strokeWidth={1.5} />
</div>
<span>{getRevealInFolderLabel()}</span>
</div>
<div className="dropdown-item" onClick={handleExportWorkspace}>
<div className="dropdown-icon">
<IconUpload size={16} strokeWidth={1.5} />
</div>
<span>Export</span>
</div>
<div className="dropdown-item" onClick={handleCloseWorkspaceClick}>
<div className="dropdown-icon">
<IconX size={16} strokeWidth={1.5} />
</div>
<span>Close</span>
</div>
</Dropdown>
)}
</div>
{/* Right side: Actions (only for regular collections) */}
{!isScratchCollection && (
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<JsSandboxMode collection={collection} />
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>
</div>
)}
</div>
</StyledWrapper>
);
};
export default CollectionHeader;

View File

@@ -1,5 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div``;
export default StyledWrapper;

View File

@@ -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 (
<StyledWrapper>
<div className="flex items-center justify-between gap-2 py-2 px-4">
<button className="flex items-center cursor-pointer hover:underline bg-transparent border-none p-0 text-inherit" onClick={viewCollectionSettings}>
<IconBox size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
</button>
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
{/* ToolHint is present within the JsSandboxMode component */}
<JsSandboxMode collection={collection} />
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>
</div>
</div>
</StyledWrapper>
);
};
export default CollectionToolBar;

View File

@@ -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 (
<>
<IconHome size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">Overview</span>
</>
);
}
case 'workspaceEnvironments': {
return (
<>
<IconWorld size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">Environments</span>
</>
);
}
}
};
@@ -80,7 +96,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
>
{getTabInfo(type, tabName)}
</div>
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
{handleCloseClick && <GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />}
</>
);
};

View File

@@ -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 (
<StyledWrapper
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
@@ -335,6 +335,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
<SpecialTab handleCloseClick={handleCloseEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} />
) : tab.type === 'global-environment-settings' ? (
<SpecialTab handleCloseClick={handleCloseGlobalEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} />
) : tab.type === 'workspaceOverview' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : tab.type === 'workspaceEnvironments' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}

View File

@@ -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 && <CollectionToolBar collection={activeCollection} />}
{activeCollection && (
<CollectionHeader
collection={activeCollection}
isScratchCollection={isScratchCollection}
/>
)}
<div className="flex items-center gap-2 pl-2" ref={collectionTabsRef}>
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>
<ActionIcon size="lg" onClick={leftSlide} aria-label="Left Chevron" style={{ marginBottom: '3px' }}>

View File

@@ -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 (
<li
className={`collection-item ${isLoading ? 'mounting' : ''} ${isSelected ? 'selected' : ''}`}
onClick={handleClick}
>
<div className="collection-item-content">
<IconDatabase size={16} strokeWidth={1.5} />
<span className="collection-item-name">{collectionName}</span>
</div>
{isLoading && (
<IconLoader2 size={16} strokeWidth={1.5} className="animate-spin" />
)}
{isFullyLoaded && (
<IconCheck size={16} strokeWidth={1.5} className="icon-success" />
)}
</li>
);
});
export default CollectionListItem;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { IconChevronRight } from '@tabler/icons';
const FolderBreadcrumbs = ({
collectionName,
breadcrumbs,
isAtRoot,
onNavigateToRoot,
onNavigateToBreadcrumb
}) => {
return (
<>
<span
className={!isAtRoot ? 'collection-name-breadcrumb' : ''}
onClick={!isAtRoot ? onNavigateToRoot : undefined}
>
{collectionName}
</span>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
onClick={(e) => {
e.stopPropagation();
onNavigateToBreadcrumb(index);
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</>
);
};
export default FolderBreadcrumbs;

View File

@@ -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;

View File

@@ -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
<StyledWrapper>
<Modal
size="md"
title="Save Request"
title={isSelectingCollection ? 'Select Collection' : 'Save Request'}
handleCancel={handleCancel}
handleConfirm={handleConfirm}
confirmText="Save"
@@ -269,212 +338,253 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
spellCheck="false"
value={requestName}
onChange={(e) => setRequestName(e.target.value)}
autoFocus={true}
autoFocus={!isSelectingCollection}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="collections-section">
<div className="collections-label">Save to Collections</div>
{collection && (
<div
className={`collection-name ${!isAtRoot ? 'collection-name-clickable' : ''}`}
onClick={!isAtRoot ? navigateToRoot : undefined}
>
<span>{collection.name}</span>
{breadcrumbs.length > 0 && (
<div className="collections-label">
{isSelectingCollection ? 'Select a collection to save to' : 'Save to Collections'}
</div>
{isScratchCollection && (
<div className="collection-name">
<span
className={isSelectingCollection ? '' : 'collection-name-breadcrumb'}
onClick={!isSelectingCollection ? () => {
setIsSelectingCollection(true);
setSelectedTargetCollectionPath(null);
reset();
} : undefined}
>
Collections
</span>
{!isSelectingCollection && (
<>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
onClick={(e) => {
e.stopPropagation();
navigateToBreadcrumb(index);
setSearchText('');
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
</>
)}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</div>
)}
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-header">
<IconFolder size={16} strokeWidth={1.5} />
<label className="new-folder-header-label">
{showFilesystemName ? 'New Folder name (in bruno)' : 'New Folder name'}
</label>
</div>
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
{isSelectingCollection ? (
<div className="collection-list">
{availableCollections.length > 0 ? (
<ul className="collection-list-items">
{availableCollections.map((coll) => {
const collPath = coll.path || coll.pathname;
return (
<CollectionListItem
key={collPath}
collectionUid={coll.uid}
collectionPath={collPath}
collectionName={coll.name}
isSelected={selectedTargetCollectionPath === collPath}
onSelect={() => handleSelectCollection(coll)}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
</div>
</div>
);
})}
</ul>
) : (
<div className="collection-empty-state">
No collections available in workspace. Please add a collection to the workspace first.
</div>
)}
</div>
) : (
<>
{!isScratchCollection && (selectedTargetCollection || collection) && (
<div className="collection-name">
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
</div>
)}
{showFilesystemName && (
<div className="new-folder-filesystem-wrapper">
<div className="flex items-center justify-between">
<label className="new-folder-filesystem-label flex items-center font-medium">
Folder Name <small className="font-normal text-muted ml-1">(on filesystem)</small>
<Help width={300} placement="top">
<p>
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
</p>
</Help>
</label>
{isEditingFolderFilename ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(true)}
/>
)}
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
{isEditingFolderFilename ? (
<div className="relative flex flex-row gap-1 items-center justify-between">
<input
type="text"
className="block textbox mt-2 w-full"
placeholder="Folder Name"
value={newFolderDirectoryName}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={(e) => setNewFolderDirectoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-header">
<IconFolder size={16} strokeWidth={1.5} />
<label className="new-folder-header-label">
{showFilesystemName ? 'New Folder name (in bruno)' : 'New Folder name'}
</label>
</div>
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">
<PathDisplay
iconType="folder"
baseName={newFolderDirectoryName}
/>
</div>
{showFilesystemName && (
<div className="new-folder-filesystem-wrapper">
<div className="flex items-center justify-between">
<label className="new-folder-filesystem-label flex items-center font-medium">
Folder Name <small className="font-normal text-muted ml-1">(on filesystem)</small>
<Help width={300} placement="top">
<p>
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
</p>
</Help>
</label>
{isEditingFolderFilename ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(true)}
/>
)}
</div>
{isEditingFolderFilename ? (
<div className="relative flex flex-row gap-1 items-center justify-between">
<input
type="text"
className="block textbox mt-2 w-full"
placeholder="Folder Name"
value={newFolderDirectoryName}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={(e) => setNewFolderDirectoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">
<PathDisplay
iconType="folder"
baseName={newFolderDirectoryName}
/>
</div>
)}
</div>
)}
</div>
)}
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
setIsEditingFolderFilename(false);
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
setIsEditingFolderFilename(false);
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
)}
</div>
</>
)}
</div>
</div>
<div className="custom-modal-footer">
<div className="footer-left">
{!showNewFolderInput && (
{!showNewFolderInput && !isSelectingCollection && (
<Button
type="button"
color="primary"
@@ -490,9 +600,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
{!isSelectingCollection && (
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
)}
</div>
</div>
</Modal>

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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 = () => {

View File

@@ -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;

View File

@@ -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')) {

View File

@@ -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 <WorkspaceOverview workspace={activeWorkspace} />;
case 'environments':
return <WorkspaceEnvironments workspace={activeWorkspace} />;
case 'preferences':
return <Preferences />;
default:
return null;
}
};
return (
<StyledWrapper className="h-full">
<div className="h-full flex flex-row">
{closeWorkspaceModalOpen && (
<CloseWorkspace
workspaceUid={activeWorkspace.uid}
onClose={() => setCloseWorkspaceModalOpen(false)}
/>
)}
<div className="main-content">
<div className="workspace-header">
<div className="workspace-title">
<IconCategory size={20} strokeWidth={1.5} />
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
) : (
<span className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace.name)}</span>
)}
</div>
{!isRenamingWorkspace && activeWorkspace.type !== 'default' && (
<Dropdown
style="new"
placement="bottom-end"
onCreate={onDropdownCreate}
icon={<IconDots size={18} strokeWidth={1.5} className="cursor-pointer" />}
>
<div className="workspace-menu-dropdown">
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
<IconEdit size={16} strokeWidth={1.5} />
<span>Rename</span>
</div>
<div className="dropdown-item" onClick={handleShowInFolder}>
<IconFolder size={16} strokeWidth={1.5} />
<span>{getRevealInFolderLabel()}</span>
</div>
<div className="dropdown-item" onClick={handleExportWorkspace}>
<IconUpload size={16} strokeWidth={1.5} />
<span>Export</span>
</div>
<div className="dropdown-item" onClick={handleCloseWorkspaceClick}>
<IconX size={16} strokeWidth={1.5} />
<span>Close</span>
</div>
</div>
</Dropdown>
)}
{workspaceNameError && isRenamingWorkspace && (
<div className="workspace-error">{workspaceNameError}</div>
)}
</div>
<WorkspaceTabs workspaceUid={activeWorkspace.uid} />
<div className="tab-content">{renderTabContent()}</div>
</div>
</div>
</StyledWrapper>
);
};
export default WorkspaceHome;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper className={`flex items-center justify-between tab-container px-2 ${tab.permanent ? 'permanent' : ''}`}>
<div className="flex items-center tab-label">
{TabIcon && (
<span className="tab-icon">
<TabIcon size={14} strokeWidth={1.5} />
</span>
)}
<span className="tab-name" title={tab.label}>
{tab.label}
</span>
</div>
{!tab.permanent && (
<div className="close-icon" onClick={handleCloseClick}>
<IconX size={14} strokeWidth={1.5} />
</div>
)}
</StyledWrapper>
);
};
export default WorkspaceTab;

View File

@@ -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 (
<StyledWrapper className={getRootClassname()}>
<div className="flex items-center pl-2">
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={leftSlide}>
<div className="flex items-center">
<IconChevronLeft size={18} strokeWidth={1.5} />
</div>
</li>
) : null}
</ul>
<div className="tabs-scroll-container" style={{ maxWidth: maxTablistWidth }} ref={scrollContainerRef}>
<ul role="tablist" ref={tabsRef}>
{workspaceTabs.map((tab, index) => (
<li
key={tab.uid}
className={getTabClassname(tab, index)}
onClick={() => handleClick(tab)}
>
<WorkspaceTab
tab={tab}
isActive={tab.uid === activeTabUid}
hasOverflow={tabOverflowStates[tab.uid]}
setHasOverflow={createSetHasOverflow(tab.uid)}
/>
</li>
))}
</ul>
</div>
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={rightSlide}>
<div className="flex items-center">
<IconChevronRight size={18} strokeWidth={1.5} />
</div>
</li>
) : null}
</ul>
</div>
</StyledWrapper>
);
};
export default WorkspaceTabs;

View File

@@ -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,

View File

@@ -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() {
<ApiSpecPanel key={activeApiSpecUid} />
) : showManageWorkspacePage ? (
<ManageWorkspace />
) : showHomePage || !activeTabUid ? (
<WorkspaceHome />
) : (
<>
<RequestTabs />

View File

@@ -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) => {

View File

@@ -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(() => {

View File

@@ -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,

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
};
};

View File

@@ -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;

View File

@@ -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);
};

View File

@@ -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
};

View File

@@ -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) {

View File

@@ -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<typeof buildCommonLocators>;
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();
});
});
});

View File

@@ -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 };