mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-27 22:54:07 +00:00
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:
committed by
GitHub
parent
3e581675cd
commit
53e158c6d1
1
.github/workflows/flaky-test-detector.yml
vendored
1
.github/workflows/flaky-test-detector.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,5 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div``;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -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;
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
238
tests/scratch-requests/scratch-requests.spec.ts
Normal file
238
tests/scratch-requests/scratch-requests.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user