mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-04 09:58:35 +00:00
Compare commits
25 Commits
workspaces
...
feature/oc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03ec2fa6e6 | ||
|
|
8a2cfd1963 | ||
|
|
4ffb447c53 | ||
|
|
3e5ae613f5 | ||
|
|
42bef4ae1e | ||
|
|
e93e545b81 | ||
|
|
4a8d787f31 | ||
|
|
f5211f6a08 | ||
|
|
57222d2500 | ||
|
|
f479e0d325 | ||
|
|
5302addda0 | ||
|
|
80b017f224 | ||
|
|
b18d582004 | ||
|
|
109394c65b | ||
|
|
c355153f26 | ||
|
|
b87a02beb3 | ||
|
|
4624ffb116 | ||
|
|
a9ce97fb1b | ||
|
|
72ce6cadeb | ||
|
|
c4ff2918a2 | ||
|
|
9972eb3de6 | ||
|
|
ebe0203415 | ||
|
|
f7ea1f8dbb | ||
|
|
cf19035b0b | ||
|
|
d9a3f74cb7 |
@@ -6,7 +6,7 @@
|
||||
|
||||
- Use 2 spaces for indentation. No tabs, just spaces – keeps everything neat and uniform.
|
||||
|
||||
- Stick to single quotes for strings. Double quotes are cool elsewhere, but here we go single.
|
||||
- Stick to single quotes for strings. For JSX/TSX attributes, use double quotes (e.g., <svg xmlns="..." viewBox="...">) to follow React conventions.
|
||||
|
||||
- Always add semicolons at the end of statements. It's like putting a period at the end of a sentence – clarity matters.
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ module.exports = runESMImports().then(() => defineConfig([
|
||||
'**/dist/**/*',
|
||||
'**/*.bru',
|
||||
'packages/bruno-js/src/sandbox/bundle-browser-rollup.js',
|
||||
'packages/bruno-app/public/static/**/*'
|
||||
'packages/bruno-app/public/static/**/*',
|
||||
'packages/bruno-app/.next/**/*',
|
||||
'packages/bruno-electron/web/**/*'
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
16
package-lock.json
generated
16
package-lock.json
generated
@@ -5669,13 +5669,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencollection/types": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.1.0.tgz",
|
||||
"integrity": "sha512-/v64ShE+KyDUAfAlO6Qd5wBwPArd603VC44eife/CdmrtPUSIiFBYcZ9gxAD7LlW99J36wb5IkMpKFDvViINiA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz",
|
||||
@@ -31912,7 +31905,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.22.0",
|
||||
"@babel/preset-typescript": "^7.22.0",
|
||||
"@opencollection/types": "0.1.0",
|
||||
"@opencollection/types": "0.2.0",
|
||||
"@rollup/plugin-commonjs": "^23.0.2",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
@@ -31932,6 +31925,13 @@
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
},
|
||||
"packages/bruno-filestore/node_modules/@opencollection/types": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.2.0.tgz",
|
||||
"integrity": "sha512-Lucjjoy+ZzfdjL0/9HF6PFlNSDG/m11VZBiR2K5XU6ChJ2XXfJyKocRB2g0tm7e5zQNMoVL3oUoDJ2gexx6xyg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/bruno-filestore/node_modules/@rollup/plugin-typescript": {
|
||||
"version": "12.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz",
|
||||
|
||||
248
packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
Normal file
248
packages/bruno-app/src/components/AppTitleBar/StyledWrapper.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
|
||||
.titlebar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 12px;
|
||||
padding-left: 70px; /* Space for macOS window controls */
|
||||
transition: padding-left 0.15s ease;
|
||||
}
|
||||
|
||||
/* When in full screen, no traffic lights so reduce padding */
|
||||
&.fullscreen .titlebar-content {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
/* Remove drag region from interactive elements */
|
||||
.workspace-name-container,
|
||||
.dropdown-item,
|
||||
.home-button,
|
||||
.env-selector-trigger,
|
||||
.dropdown,
|
||||
button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Left section */
|
||||
.titlebar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: 10px;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* When in full screen, no traffic lights so remove margin-left */
|
||||
&.fullscreen .titlebar-left {
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
/* Home button */
|
||||
.home-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
/* Workspace Name Dropdown Trigger */
|
||||
.workspace-name-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* Center section - Bruno branding */
|
||||
.titlebar-center {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
|
||||
.bruno-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Right section */
|
||||
.titlebar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Action buttons in right section */
|
||||
.titlebar-action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
}
|
||||
}
|
||||
|
||||
/* Draggable region */
|
||||
.drag-region {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* Workspace Dropdown Styles */
|
||||
.workspace-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px !important;
|
||||
margin: 0 !important;
|
||||
|
||||
&.active {
|
||||
.check-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.pin-btn:not(.pinned) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.dropdown.color};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workspace-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: ${(props) => props.theme.workspace?.accent || props.theme.colors?.text?.yellow};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pin-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||
opacity: 0;
|
||||
|
||||
&.pinned {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.dropdown.hoverBg};
|
||||
color: ${(props) => props.theme.dropdown.mutedText};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Adjust for non-macOS platforms */
|
||||
body:not(.os-mac) & {
|
||||
.titlebar-content {
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Leave room for Windows caption buttons when the overlay is enabled */
|
||||
body.os-windows & {
|
||||
.titlebar-content {
|
||||
padding-right: 120px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
244
packages/bruno-app/src/components/AppTitleBar/index.js
Normal file
244
packages/bruno-app/src/components/AppTitleBar/index.js
Normal file
@@ -0,0 +1,244 @@
|
||||
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconLayoutColumns, IconLayoutRows, IconPin, IconPinned, IconPlus } from '@tabler/icons';
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { savePreferences, showHomePage, 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 Bruno from 'components/Bruno';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
|
||||
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { toTitleCase } from 'utils/common/index';
|
||||
|
||||
const AppTitleBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
|
||||
// Listen for fullscreen changes
|
||||
useEffect(() => {
|
||||
const { ipcRenderer } = window;
|
||||
if (!ipcRenderer) return;
|
||||
|
||||
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
|
||||
setIsFullScreen(true);
|
||||
});
|
||||
|
||||
const removeLeaveFullScreenListener = ipcRenderer.on('main:leave-full-screen', () => {
|
||||
setIsFullScreen(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
removeEnterFullScreenListener();
|
||||
removeLeaveFullScreenListener();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get workspace info
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
// Sort workspaces according to preferences
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
return sortWorkspaces(workspaces, preferences);
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
const [showWorkspaceDropdown, setShowWorkspaceDropdown] = useState(false);
|
||||
const workspaceDropdownTippyRef = useRef();
|
||||
const onWorkspaceDropdownCreate = (ref) => (workspaceDropdownTippyRef.current = ref);
|
||||
|
||||
const WorkspaceName = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="workspace-name-container" onClick={() => setShowWorkspaceDropdown(!showWorkspaceDropdown)}>
|
||||
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleHomeClick = () => {
|
||||
dispatch(showHomePage());
|
||||
};
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
dispatch(switchWorkspace(workspaceUid));
|
||||
setShowWorkspaceDropdown(false);
|
||||
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
|
||||
};
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
setShowWorkspaceDropdown(false);
|
||||
try {
|
||||
await dispatch(openWorkspaceDialog());
|
||||
toast.success('Workspace opened successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to open workspace');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setShowWorkspaceDropdown(false);
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePinWorkspace = useCallback((workspaceUid, e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const newPreferences = toggleWorkspacePin(workspaceUid, preferences);
|
||||
dispatch(savePreferences(newPreferences));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
};
|
||||
|
||||
const handleToggleDevtools = () => {
|
||||
if (isConsoleOpen) {
|
||||
dispatch(closeConsole());
|
||||
} else {
|
||||
dispatch(openConsole());
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVerticalLayout = () => {
|
||||
const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
const updatedPreferences = {
|
||||
...preferences,
|
||||
layout: {
|
||||
...preferences?.layout || {},
|
||||
responsePaneOrientation: newOrientation
|
||||
}
|
||||
};
|
||||
dispatch(savePreferences(updatedPreferences));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`app-titlebar ${isFullScreen ? 'fullscreen' : ''}`}>
|
||||
{createWorkspaceModalOpen && (
|
||||
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
|
||||
)}
|
||||
|
||||
<div className="titlebar-content">
|
||||
{/* Left section: Home + Workspace */}
|
||||
<div className="titlebar-left">
|
||||
<button className="home-button" onClick={handleHomeClick} title="Home">
|
||||
<IconHome size={16} stroke={1.5} />
|
||||
</button>
|
||||
|
||||
{/* Workspace Dropdown */}
|
||||
<Dropdown
|
||||
onCreate={onWorkspaceDropdownCreate}
|
||||
icon={<WorkspaceName />}
|
||||
placement="bottom-start"
|
||||
style="new"
|
||||
visible={showWorkspaceDropdown}
|
||||
onClickOutside={() => setShowWorkspaceDropdown(false)}
|
||||
>
|
||||
{sortedWorkspaces.map((workspace) => {
|
||||
const isActive = workspace.uid === activeWorkspaceUid;
|
||||
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.uid}
|
||||
className={`dropdown-item workspace-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleWorkspaceSwitch(workspace.uid)}
|
||||
>
|
||||
<span className="workspace-name">{toTitleCase(workspace.name)}</span>
|
||||
<div className="workspace-actions">
|
||||
{workspace.type !== 'default' && (
|
||||
<button
|
||||
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
|
||||
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
|
||||
title={isPinned ? 'Unpin workspace' : 'Pin workspace'}
|
||||
>
|
||||
{isPinned ? (
|
||||
<IconPinned size={14} stroke={1.5} />
|
||||
) : (
|
||||
<IconPin size={14} stroke={1.5} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="label-item border-top">Workspaces</div>
|
||||
|
||||
<div className="dropdown-item" onClick={handleCreateWorkspace}>
|
||||
<IconPlus size={16} stroke={1.5} className="icon" />
|
||||
Create workspace
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={handleOpenWorkspace}>
|
||||
<IconFolder size={16} stroke={1.5} className="icon" />
|
||||
Open workspace
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
{/* Center section: Bruno logo + text */}
|
||||
<div className="titlebar-center">
|
||||
<Bruno width={18} />
|
||||
<span className="bruno-text">Bruno</span>
|
||||
</div>
|
||||
|
||||
{/* Right section: Action buttons */}
|
||||
<div className="titlebar-right">
|
||||
{/* Toggle sidebar */}
|
||||
<button
|
||||
className="titlebar-action-button"
|
||||
onClick={handleToggleSidebar}
|
||||
title={sidebarCollapsed ? 'Show sidebar' : 'Hide sidebar'}
|
||||
aria-label="Toggle Sidebar"
|
||||
data-testid="toggle-sidebar-button"
|
||||
>
|
||||
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
{/* Toggle devtools */}
|
||||
<button
|
||||
className="titlebar-action-button"
|
||||
onClick={handleToggleDevtools}
|
||||
title={isConsoleOpen ? 'Hide devtools' : 'Show devtools'}
|
||||
aria-label="Toggle Devtools"
|
||||
data-testid="toggle-devtools-button"
|
||||
>
|
||||
<IconBottombarToggle collapsed={!isConsoleOpen} size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
|
||||
{/* Toggle vertical layout */}
|
||||
<button
|
||||
className="titlebar-action-button"
|
||||
onClick={handleToggleVerticalLayout}
|
||||
title={orientation === 'horizontal' ? 'Switch to vertical layout' : 'Switch to horizontal layout'}
|
||||
aria-label="Toggle Vertical Layout"
|
||||
data-testid="toggle-vertical-layout-button"
|
||||
>
|
||||
{orientation === 'horizontal' ? (
|
||||
<IconLayoutColumns size={16} stroke={1.5} />
|
||||
) : (
|
||||
<IconLayoutRows size={16} stroke={1.5} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppTitleBar;
|
||||
@@ -373,7 +373,7 @@ const ClientCertSettings = ({ collection }) => {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-6 flex flex-row gap-2 items-center">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" data-testid="add-client-cert">
|
||||
Add
|
||||
</button>
|
||||
<div className="h-4 border-l border-gray-600"></div>
|
||||
|
||||
@@ -3,15 +3,23 @@ import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { IconBox, IconFolder, IconWorld, IconApi, IconShare } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
|
||||
import { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
|
||||
|
||||
const isCollectionLoading = areItemsLoading(collection);
|
||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
|
||||
const collectionEnvironmentCount = collection.environments?.length || 0;
|
||||
const globalEnvironmentCount = globalEnvironments?.length || 0;
|
||||
|
||||
const handleToggleShowShareCollectionModal = (value) => (e) => {
|
||||
toggleShowShareCollectionModal(value);
|
||||
};
|
||||
@@ -39,9 +47,24 @@ const Info = ({ collection }) => {
|
||||
<IconWorld className="w-5 h-5 text-green-500" stroke={1.5} />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="font-medium">Environments</div>
|
||||
<div className="mt-1 text-muted text-xs">
|
||||
{collection.environments?.length || 0} environment{collection.environments?.length !== 1 ? 's' : ''} configured
|
||||
<div className="font-medium text-sm">Environments</div>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
|
||||
onClick={() => {
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
>
|
||||
{collectionEnvironmentCount} collection environment{collectionEnvironmentCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-link cursor-pointer hover:underline text-left bg-transparent"
|
||||
onClick={() => dispatch(updateGlobalEnvironmentSettingsModalVisibility(true))}
|
||||
>
|
||||
{globalEnvironmentCount} global environment{globalEnvironmentCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,18 +4,23 @@ import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import DeprecationWarning from 'components/DeprecationWarning';
|
||||
|
||||
const Vars = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
|
||||
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
|
||||
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1 mt-2">
|
||||
<div className="mb-1 title text-xs">Pre Request</div>
|
||||
<VarsTable collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mt-1 mb-1 title text-xs">Post Response</div>
|
||||
<VarsTable collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
|
||||
Save
|
||||
|
||||
@@ -32,17 +32,32 @@ const CollectionSettings = ({ collection }) => {
|
||||
const hasTests = root?.request?.tests;
|
||||
const hasDocs = root?.docs;
|
||||
|
||||
const headers = collection.draft?.root ? get(collection, 'draft.root.request.headers', []) : get(collection, 'root.request.headers', []);
|
||||
const headers = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.headers', [])
|
||||
: get(collection, 'root.request.headers', []);
|
||||
const activeHeadersCount = headers.filter((header) => header.enabled).length;
|
||||
|
||||
const requestVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.req', []) : get(collection, 'root.request.vars.req', []);
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
|
||||
const authMode = (collection.draft?.root ? get(collection, 'draft.root.request.auth', {}) : get(collection, 'root.request.auth', {})).mode || 'none';
|
||||
const requestVars = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.vars.req', [])
|
||||
: get(collection, 'root.request.vars.req', []);
|
||||
const responseVars = collection.draft?.root
|
||||
? get(collection, 'draft.root.request.vars.res', [])
|
||||
: get(collection, 'root.request.vars.res', []);
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
const authMode
|
||||
= (collection.draft?.root ? get(collection, 'draft.root.request.auth', {}) : get(collection, 'root.request.auth', {}))
|
||||
.mode || 'none';
|
||||
|
||||
const proxyConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.proxy', {}) : get(collection, 'brunoConfig.proxy', {});
|
||||
const proxyConfig = collection.draft?.brunoConfig
|
||||
? get(collection, 'draft.brunoConfig.proxy', {})
|
||||
: get(collection, 'brunoConfig.proxy', {});
|
||||
const proxyEnabled = proxyConfig.hostname ? true : false;
|
||||
const clientCertConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.clientCertificates.certs', []) : get(collection, 'brunoConfig.clientCertificates.certs', []);
|
||||
const protobufConfig = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.protobuf', {}) : get(collection, 'brunoConfig.protobuf', {});
|
||||
const clientCertConfig = collection.draft?.brunoConfig
|
||||
? get(collection, 'draft.brunoConfig.clientCertificates.certs', [])
|
||||
: get(collection, 'brunoConfig.clientCertificates.certs', []);
|
||||
const protobufConfig = collection.draft?.brunoConfig
|
||||
? get(collection, 'draft.brunoConfig.protobuf', {})
|
||||
: get(collection, 'brunoConfig.protobuf', {});
|
||||
|
||||
const getTabPanel = (tab) => {
|
||||
switch (tab) {
|
||||
@@ -68,11 +83,7 @@ const CollectionSettings = ({ collection }) => {
|
||||
return <ProxySettings collection={collection} />;
|
||||
}
|
||||
case 'clientCert': {
|
||||
return (
|
||||
<ClientCertSettings
|
||||
collection={collection}
|
||||
/>
|
||||
);
|
||||
return <ClientCertSettings collection={collection} />;
|
||||
}
|
||||
case 'protobuf': {
|
||||
return <Protobuf collection={collection} />;
|
||||
|
||||
@@ -3,7 +3,7 @@ import find from 'lodash/find';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
import { updateEnvironmentSettingsModalVisibility, updateGlobalEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
|
||||
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -20,8 +20,6 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const dropdownTippyRef = useRef();
|
||||
const [activeTab, setActiveTab] = useState('collection');
|
||||
const [showGlobalSettings, setShowGlobalSettings] = useState(false);
|
||||
const [showCollectionSettings, setShowCollectionSettings] = useState(false);
|
||||
const [showCreateGlobalModal, setShowCreateGlobalModal] = useState(false);
|
||||
const [showImportGlobalModal, setShowImportGlobalModal] = useState(false);
|
||||
const [showCreateCollectionModal, setShowCreateCollectionModal] = useState(false);
|
||||
@@ -29,6 +27,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
|
||||
const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen);
|
||||
const activeGlobalEnvironment = activeGlobalEnvironmentUid
|
||||
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
|
||||
: null;
|
||||
@@ -79,9 +79,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
const handleSettingsClick = () => {
|
||||
if (activeTab === 'collection') {
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
setShowCollectionSettings(true);
|
||||
} else {
|
||||
setShowGlobalSettings(true);
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
|
||||
}
|
||||
dropdownTippyRef.current.hide();
|
||||
};
|
||||
@@ -108,9 +107,8 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
|
||||
// Modal handlers
|
||||
const handleCloseSettings = () => {
|
||||
setShowGlobalSettings(false);
|
||||
setShowCollectionSettings(false);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(false));
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(false));
|
||||
};
|
||||
|
||||
// Calculate dropdown width based on the longest environment name.
|
||||
@@ -220,7 +218,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
</div>
|
||||
|
||||
{/* Modals - Rendered outside dropdown to avoid conflicts */}
|
||||
{showGlobalSettings && (
|
||||
{isGlobalEnvironmentSettingsModalOpen && (
|
||||
<GlobalEnvironmentSettings
|
||||
globalEnvironments={globalEnvironments}
|
||||
collection={collection}
|
||||
@@ -229,13 +227,15 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
|
||||
{isEnvironmentSettingsModalOpen && (
|
||||
<EnvironmentSettings collection={collection} onClose={handleCloseSettings} />
|
||||
)}
|
||||
|
||||
{showCreateGlobalModal && (
|
||||
<CreateGlobalEnvironment
|
||||
onClose={() => setShowCreateGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -245,7 +245,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
type="global"
|
||||
onClose={() => setShowImportGlobalModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowGlobalSettings(true);
|
||||
dispatch(updateGlobalEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -255,7 +255,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
collection={collection}
|
||||
onClose={() => setShowCreateCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -266,7 +266,7 @@ const EnvironmentSelector = ({ collection }) => {
|
||||
collection={collection}
|
||||
onClose={() => setShowImportCollectionModal(false)}
|
||||
onEnvironmentCreated={() => {
|
||||
setShowCollectionSettings(true);
|
||||
dispatch(updateEnvironmentSettingsModalVisibility(true));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -8,13 +8,19 @@ import { useDispatch } from 'react-redux';
|
||||
const Vars = ({ collection, folder }) => {
|
||||
const dispatch = useDispatch();
|
||||
const requestVars = folder.draft ? get(folder, 'draft.request.vars.req', []) : get(folder, 'root.request.vars.req', []);
|
||||
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
|
||||
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="flex-1 mt-2">
|
||||
<div className="mb-1 title text-xs">Pre Request</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="mt-1 mb-1 title text-xs">Post Response</div>
|
||||
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={handleSave}>
|
||||
Save
|
||||
|
||||
@@ -28,7 +28,8 @@ const FolderSettings = ({ collection, folder }) => {
|
||||
const activeHeadersCount = headers.filter((header) => header.enabled).length;
|
||||
|
||||
const requestVars = folderRoot?.request?.vars?.req || [];
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length;
|
||||
const responseVars = folderRoot?.request?.vars?.res || [];
|
||||
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
|
||||
|
||||
const auth = get(folderRoot, 'request.auth.mode');
|
||||
const hasAuth = auth && auth !== 'none';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
|
||||
const IconBottombarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-bottombar ${className}`} {...rest}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
|
||||
<path d="M4 15l16 0" />
|
||||
{!collapsed && (
|
||||
<rect x="4.6" y="15.6" width="14.8" height="2.8" rx="0.8" fill="currentColor" />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconBottombarToggle;
|
||||
@@ -99,13 +99,16 @@ const HttpRequestPane = ({ item, collection }) => {
|
||||
const tests = getPropertyFromDraftOrRequest('request.tests');
|
||||
const docs = getPropertyFromDraftOrRequest('request.docs');
|
||||
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
|
||||
const responseVars = getPropertyFromDraftOrRequest('request.vars.res');
|
||||
const auth = getPropertyFromDraftOrRequest('request.auth');
|
||||
const tags = getPropertyFromDraftOrRequest('tags');
|
||||
|
||||
const activeParamsLength = params.filter((param) => param.enabled).length;
|
||||
const activeHeadersLength = headers.filter((header) => header.enabled).length;
|
||||
const activeAssertionsLength = assertions.filter((assertion) => assertion.enabled).length;
|
||||
const activeVarsLength = requestVars.filter((request) => request.enabled).length;
|
||||
const activeVarsLength
|
||||
= requestVars.filter((request) => request.enabled).length
|
||||
+ responseVars.filter((response) => response.enabled).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeParamsLength === 0 && body.mode !== 'none') {
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import get from 'lodash/get';
|
||||
import VarsTable from './VarsTable';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import DeprecationWarning from 'components/DeprecationWarning';
|
||||
|
||||
const Vars = ({ item, collection }) => {
|
||||
const requestVars = item.draft ? get(item, 'draft.request.vars.req') : get(item, 'request.vars.req');
|
||||
@@ -11,8 +10,13 @@ const Vars = ({ item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper className="w-full flex flex-col">
|
||||
<div className="mt-2">
|
||||
<div className="mb-1 title text-xs">Pre Request</div>
|
||||
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-1 mb-1 title text-xs">Post Response</div>
|
||||
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import HttpRequestPane from 'components/RequestPane/HttpRequestPane';
|
||||
import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
|
||||
import ResponsePane from 'components/ResponsePane';
|
||||
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
|
||||
import Welcome from 'components/Welcome';
|
||||
import { findItemInCollection } from 'utils/collections';
|
||||
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import RequestNotFound from './RequestNotFound';
|
||||
@@ -34,6 +33,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';
|
||||
|
||||
const MIN_LEFT_PANE_WIDTH = 300;
|
||||
const MIN_RIGHT_PANE_WIDTH = 350;
|
||||
@@ -137,7 +137,7 @@ const RequestTabPanel = () => {
|
||||
}, [dragging]);
|
||||
|
||||
if (!activeTabUid) {
|
||||
return <Welcome />;
|
||||
return <WorkspaceHome />;
|
||||
}
|
||||
|
||||
if (!focusedTab || !focusedTab.uid || !focusedTab.collectionUid) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { uuid } from 'utils/common';
|
||||
import { IconFiles, IconRun, IconEye, IconSettings } from '@tabler/icons';
|
||||
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';
|
||||
@@ -45,7 +45,7 @@ const CollectionToolBar = ({ collection }) => {
|
||||
<StyledWrapper>
|
||||
<div className="flex items-center py-2 px-4">
|
||||
<div className="flex flex-1 items-center cursor-pointer hover:underline" onClick={viewCollectionSettings}>
|
||||
<IconFiles size={18} strokeWidth={1.5} />
|
||||
<IconBox size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-3 items-center justify-end">
|
||||
|
||||
@@ -8,8 +8,7 @@ import ExampleIcon from 'components/Icons/ExampleIcon';
|
||||
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
|
||||
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
|
||||
import StyledWrapper from '../RequestTab/StyledWrapper';
|
||||
import CloseTabIcon from '../RequestTab/CloseTabIcon';
|
||||
import DraftTabIcon from '../RequestTab/DraftTabIcon';
|
||||
import GradientCloseButton from '../RequestTab/GradientCloseButton';
|
||||
|
||||
const ExampleTab = ({ tab, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -59,7 +58,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
if (!item || !example) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className="flex items-center justify-between tab-container px-1"
|
||||
className="flex items-center justify-between tab-container px-3"
|
||||
onMouseUp={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
@@ -75,7 +74,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-1">
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-3">
|
||||
{showConfirmClose && (
|
||||
<ConfirmRequestClose
|
||||
item={item}
|
||||
@@ -116,13 +115,13 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExampleIcon size={16} color="currentColor" className="mr-2 text-gray-500 flex-shrink-0" />
|
||||
<ExampleIcon size={14} color="currentColor" className="mr-1.5 text-gray-500 flex-shrink-0" />
|
||||
<span className="tab-name" title={example.name}>
|
||||
{example.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex px-2 close-icon-container"
|
||||
<GradientCloseButton
|
||||
hasChanges={hasChanges}
|
||||
onClick={(e) => {
|
||||
if (!hasChanges) {
|
||||
return handleCloseClick(e);
|
||||
@@ -132,13 +131,7 @@ const ExampleTab = ({ tab, collection }) => {
|
||||
e.preventDefault();
|
||||
setShowConfirmClose(true);
|
||||
}}
|
||||
>
|
||||
{!hasChanges ? (
|
||||
<CloseTabIcon />
|
||||
) : (
|
||||
<DraftTabIcon />
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div.attrs((props) => ({
|
||||
style: {
|
||||
'--gradient-color': props.theme.requestTabs.bg,
|
||||
'--gradient-color-active': props.theme.bg
|
||||
}
|
||||
}))`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
position: absolute;
|
||||
width: 44px;
|
||||
height: 100%;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding-right: 4px;
|
||||
z-index: 3;
|
||||
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--gradient-color) 40%
|
||||
);
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
li.active & {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--gradient-color-active) 40%
|
||||
);
|
||||
}
|
||||
|
||||
li:hover &,
|
||||
&.has-changes {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.close-icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
|
||||
.close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.hoverColor};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.color};
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
|
||||
.has-changes-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.draft-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.close-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.has-changes:not(li:hover &) {
|
||||
.draft-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.close-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
li:hover &.has-changes {
|
||||
.draft-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.close-icon-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import CloseTabIcon from '../CloseTabIcon';
|
||||
import DraftTabIcon from '../DraftTabIcon';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GradientCloseButton = ({ onClick, hasChanges = false }) => {
|
||||
return (
|
||||
<StyledWrapper className={`close-gradient ${hasChanges ? 'has-changes' : ''}`}>
|
||||
<div className="close-icon-container" onClick={onClick} data-testid="request-tab-close-icon">
|
||||
<span className="draft-icon-wrapper">
|
||||
<DraftTabIcon />
|
||||
</span>
|
||||
<span className="close-icon-wrapper">
|
||||
<CloseTabIcon />
|
||||
</span>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default GradientCloseButton;
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import CloseTabIcon from './CloseTabIcon';
|
||||
import DraftTabIcon from './DraftTabIcon';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
|
||||
|
||||
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
|
||||
@@ -8,49 +7,49 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
switch (type) {
|
||||
case 'collection-settings': {
|
||||
return (
|
||||
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
|
||||
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Collection</span>
|
||||
</div>
|
||||
<>
|
||||
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Collection</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'collection-overview': {
|
||||
return (
|
||||
<>
|
||||
<IconSettings size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Collection</span>
|
||||
<IconSettings size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Overview</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'security-settings': {
|
||||
return (
|
||||
<>
|
||||
<IconShieldLock size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1">Security</span>
|
||||
<IconShieldLock size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Security</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'folder-settings': {
|
||||
return (
|
||||
<div onDoubleClick={handleDoubleClick} className="flex items-center flex-nowrap overflow-hidden">
|
||||
<IconFolder size={18} strokeWidth={1.5} className="text-yellow-600 min-w-[18px]" />
|
||||
<span className="ml-1 leading-6 truncate">{tabName || 'Folder'}</span>
|
||||
</div>
|
||||
<>
|
||||
<IconFolder size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">{tabName || 'Folder'}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'variables': {
|
||||
return (
|
||||
<>
|
||||
<IconVariable size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Variables</span>
|
||||
<IconVariable size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Variables</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'collection-runner': {
|
||||
return (
|
||||
<>
|
||||
<IconRun size={18} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<span className="ml-1 leading-6">Runner</span>
|
||||
<IconRun size={14} strokeWidth={1.5} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="ml-1 tab-name">Runner</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -59,10 +58,13 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center tab-label pl-2">{getTabInfo(type, tabName)}</div>
|
||||
<div className="flex px-2 close-icon-container" onClick={(e) => handleCloseClick(e)}>
|
||||
{hasDraft ? <DraftTabIcon /> : <CloseTabIcon />}
|
||||
<div
|
||||
className="flex items-baseline tab-label"
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{getTabInfo(type, tabName)}
|
||||
</div>
|
||||
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,43 +1,30 @@
|
||||
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-method {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close-icon-container {
|
||||
min-height: 20px;
|
||||
min-width: 24px;
|
||||
margin-left: 4px;
|
||||
border-radius: 3px;
|
||||
|
||||
.close-icon {
|
||||
display: none;
|
||||
color: ${(props) => props.theme.requestTabs.icon.color};
|
||||
width: 8px;
|
||||
padding-bottom: 6px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:hover .close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.hoverColor};
|
||||
background-color: ${(props) => props.theme.requestTabs.icon.hoverBg};
|
||||
}
|
||||
|
||||
.has-changes-icon {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tab-method {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
}
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo } from 'react';
|
||||
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
import { saveRequest, saveCollectionRoot, saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
@@ -17,16 +17,17 @@ import StyledWrapper from './StyledWrapper';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
|
||||
import NewRequest from 'components/Sidebar/NewRequest/index';
|
||||
import CloseTabIcon from './CloseTabIcon';
|
||||
import DraftTabIcon from './DraftTabIcon';
|
||||
import GradientCloseButton from './GradientCloseButton';
|
||||
import { flattenItems } from 'utils/collections/index';
|
||||
import { closeWsConnection } from 'utils/network/index';
|
||||
import ExampleTab from '../ExampleTab';
|
||||
|
||||
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
|
||||
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid, hasOverflow, setHasOverflow }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
|
||||
const tabNameRef = useRef(null);
|
||||
const lastOverflowStateRef = useRef(null);
|
||||
const [showConfirmClose, setShowConfirmClose] = useState(false);
|
||||
const [showConfirmCollectionClose, setShowConfirmCollectionClose] = useState(false);
|
||||
const [showConfirmFolderClose, setShowConfirmFolderClose] = useState(false);
|
||||
@@ -36,6 +37,48 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
const item = findItemInCollection(collection, tab.uid);
|
||||
|
||||
const method = useMemo(() => {
|
||||
if (!item) return;
|
||||
switch (item.type) {
|
||||
case 'grpc-request':
|
||||
return 'gRPC';
|
||||
case 'ws-request':
|
||||
return 'WS';
|
||||
case 'graphql-request':
|
||||
return 'GQL';
|
||||
default:
|
||||
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item || !tabNameRef.current || !setHasOverflow) return;
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (tabNameRef.current && setHasOverflow) {
|
||||
const hasOverflow = tabNameRef.current.scrollWidth > tabNameRef.current.clientWidth;
|
||||
if (lastOverflowStateRef.current !== hasOverflow) {
|
||||
lastOverflowStateRef.current = hasOverflow;
|
||||
setHasOverflow(hasOverflow);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(checkOverflow, 0);
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
|
||||
if (tabNameRef.current) {
|
||||
resizeObserver.observe(tabNameRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [item, item?.name, method, setHasOverflow]);
|
||||
|
||||
const handleCloseClick = (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -105,11 +148,12 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
|
||||
const hasDraft = tab.type === 'collection-settings' && collection?.draft;
|
||||
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
|
||||
|
||||
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
|
||||
return (
|
||||
<StyledWrapper
|
||||
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? 'italic' : ''}`}
|
||||
onMouseUp={handleMouseUp} // Add middle-click behavior here
|
||||
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
{showConfirmCollectionClose && tab.type === 'collection-settings' && (
|
||||
<ConfirmCollectionClose
|
||||
@@ -192,21 +236,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
);
|
||||
}
|
||||
|
||||
const getMethodText = useCallback((item) => {
|
||||
if (!item) return;
|
||||
|
||||
switch (item.type) {
|
||||
case 'grpc-request':
|
||||
return 'gRPC';
|
||||
case 'ws-request':
|
||||
return 'WS';
|
||||
case 'graphql-request':
|
||||
return 'GQL';
|
||||
default:
|
||||
return item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
|
||||
}
|
||||
}, [item]);
|
||||
|
||||
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
|
||||
|
||||
if (!item) {
|
||||
@@ -228,10 +257,36 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}
|
||||
|
||||
const isWS = item.type === 'ws-request';
|
||||
const method = getMethodText(item);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOverflow = () => {
|
||||
if (tabNameRef.current && setHasOverflow) {
|
||||
const hasOverflow = tabNameRef.current.scrollWidth > tabNameRef.current.clientWidth;
|
||||
if (lastOverflowStateRef.current !== hasOverflow) {
|
||||
lastOverflowStateRef.current = hasOverflow;
|
||||
setHasOverflow(hasOverflow);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(checkOverflow, 0);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
checkOverflow();
|
||||
});
|
||||
|
||||
if (tabNameRef.current) {
|
||||
resizeObserver.observe(tabNameRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [item.name, method, setHasOverflow]);
|
||||
|
||||
return (
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-1">
|
||||
<StyledWrapper className="flex items-center justify-between tab-container px-2">
|
||||
{showConfirmClose && (
|
||||
<ConfirmRequestClose
|
||||
item={item}
|
||||
@@ -268,7 +323,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex items-baseline tab-label pl-2 ${tab.preview ? 'italic' : ''}`}
|
||||
className={`flex items-baseline tab-label ${tab.preview ? 'italic' : ''}`}
|
||||
onContextMenu={handleRightClick}
|
||||
onDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))}
|
||||
onMouseUp={(e) => {
|
||||
@@ -284,7 +339,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
|
||||
{method}
|
||||
</span>
|
||||
<span className="ml-1 tab-name" title={item.name}>
|
||||
<span ref={tabNameRef} className="ml-1 tab-name" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
<RequestTabMenu
|
||||
@@ -297,25 +352,19 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex px-2 close-icon-container"
|
||||
<GradientCloseButton
|
||||
hasChanges={hasChanges}
|
||||
onClick={(e) => {
|
||||
if (!hasChanges) {
|
||||
isWS && closeWsConnection(item.uid);
|
||||
return handleCloseClick(e);
|
||||
};
|
||||
}
|
||||
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowConfirmClose(true);
|
||||
}}
|
||||
>
|
||||
{!hasChanges ? (
|
||||
<CloseTabIcon />
|
||||
) : (
|
||||
<DraftTabIcon />
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
@@ -349,7 +398,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
}
|
||||
|
||||
dispatch(closeTabs({ tabUids: [tabUid] }));
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
function handleRevertChanges(event) {
|
||||
@@ -368,7 +417,7 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
|
||||
collectionUid: collection.uid
|
||||
}));
|
||||
}
|
||||
} catch (err) {}
|
||||
} catch (err) { }
|
||||
}
|
||||
|
||||
function handleCloseOtherTabs(event) {
|
||||
|
||||
@@ -1,13 +1,44 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
|
||||
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;
|
||||
padding: 0 2px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
overflow: scroll;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -17,57 +48,128 @@ const Wrapper = styled.div`
|
||||
|
||||
li {
|
||||
display: inline-flex;
|
||||
max-width: 150px;
|
||||
border: 1px solid transparent;
|
||||
max-width: 180px;
|
||||
min-width: 80px;
|
||||
list-style: none;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
cursor: pointer;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
height: 38px;
|
||||
|
||||
margin-right: 6px;
|
||||
font-size: 0.8125rem;
|
||||
position: relative;
|
||||
margin-right: 3px;
|
||||
color: ${(props) => props.theme.requestTabs.color};
|
||||
background: ${(props) => props.theme.requestTabs.bg};
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
padding: 6px 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.15s ease;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.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: 10px;
|
||||
}
|
||||
|
||||
&.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% - 24px),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
${(props) => props.theme.requestTabs.color} 0%,
|
||||
${(props) => props.theme.requestTabs.color} calc(100% - 24px),
|
||||
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.requestTabs.active.bg};
|
||||
}
|
||||
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;
|
||||
font-weight: 500;
|
||||
z-index: 2;
|
||||
margin-bottom: -2px;
|
||||
padding-bottom: 12px;
|
||||
|
||||
&.active {
|
||||
.close-icon-container .close-icon {
|
||||
display: block;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: -8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border-bottom-right-radius: 8px;
|
||||
box-shadow: 2px 2px 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};
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.close-icon-container .close-icon {
|
||||
display: block;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -8px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border-bottom-left-radius: 8px;
|
||||
box-shadow: -2px 2px 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};
|
||||
}
|
||||
}
|
||||
|
||||
&.short-tab {
|
||||
vertical-align: bottom;
|
||||
width: 34px;
|
||||
min-width: 34px;
|
||||
max-width: 34px;
|
||||
padding: 3px 0px;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
max-width: 32px;
|
||||
padding: 5px 0;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: ${(props) => props.theme.requestTabs.shortTab.color};
|
||||
background-color: ${(props) => props.theme.requestTabs.shortTab.bg};
|
||||
position: relative;
|
||||
top: -1px;
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
flex-shrink: 0;
|
||||
|
||||
> div {
|
||||
padding: 3px 4px;
|
||||
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;
|
||||
}
|
||||
|
||||
> div.home-icon-container {
|
||||
@@ -81,19 +183,23 @@ const Wrapper = styled.div`
|
||||
}
|
||||
|
||||
svg {
|
||||
height: 22px;
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> div {
|
||||
background-color: ${(props) => props.theme.requestTabs.shortTab.hoverBg};
|
||||
color: ${(props) => props.theme.requestTabs.shortTab.hoverColor};
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.has-chevrons ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import find from 'lodash/find';
|
||||
import filter from 'lodash/filter';
|
||||
import classnames from 'classnames';
|
||||
@@ -14,7 +14,10 @@ import DraggableTab from './DraggableTab';
|
||||
const RequestTabs = () => {
|
||||
const dispatch = useDispatch();
|
||||
const tabsRef = useRef();
|
||||
const scrollContainerRef = useRef();
|
||||
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
|
||||
const [tabOverflowStates, setTabOverflowStates] = useState({});
|
||||
const [showChevrons, setShowChevrons] = useState(false);
|
||||
const tabs = useSelector((state) => state.tabs.tabs);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
@@ -22,10 +25,48 @@ const RequestTabs = () => {
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const screenWidth = useSelector((state) => state.app.screenWidth);
|
||||
|
||||
const createSetHasOverflow = useCallback((tabUid) => {
|
||||
return (hasOverflow) => {
|
||||
setTabOverflowStates((prev) => {
|
||||
if (prev[tabUid] === hasOverflow) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[tabUid]: hasOverflow
|
||||
};
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
const activeCollection = find(collections, (c) => c.uid === activeTab?.collectionUid);
|
||||
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabUid || !activeTab) return;
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (tabsRef.current && scrollContainerRef.current) {
|
||||
const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth;
|
||||
setShowChevrons(hasOverflow);
|
||||
}
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
const resizeObserver = new ResizeObserver(checkOverflow);
|
||||
if (scrollContainerRef.current) {
|
||||
resizeObserver.observe(scrollContainerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [activeTabUid, activeTab, collectionRequestTabs.length, screenWidth, leftSidebarWidth, sidebarCollapsed]);
|
||||
|
||||
const getTabClassname = (tab, index) => {
|
||||
return classnames('request-tab select-none', {
|
||||
'active': tab.uid === activeTabUid,
|
||||
'last-tab': tabs && tabs.length && index === tabs.length - 1
|
||||
'last-tab': tabs && tabs.length && index === tabs.length - 1,
|
||||
'has-overflow': tabOverflowStates[tab.uid]
|
||||
});
|
||||
};
|
||||
|
||||
@@ -43,31 +84,22 @@ const RequestTabs = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
if (!activeTab) {
|
||||
return <StyledWrapper>Something went wrong!</StyledWrapper>;
|
||||
}
|
||||
|
||||
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
|
||||
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
|
||||
|
||||
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
|
||||
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
|
||||
const tabsWidth = collectionRequestTabs.length * 150 + 34; // 34: (+)icon
|
||||
const showChevrons = maxTablistWidth < tabsWidth;
|
||||
|
||||
const leftSlide = () => {
|
||||
tabsRef.current.scrollBy({
|
||||
scrollContainerRef.current?.scrollBy({
|
||||
left: -120,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
// todo: bring new tab to focus if its not in focus
|
||||
// tabsRef.current.scrollLeft
|
||||
|
||||
const rightSlide = () => {
|
||||
tabsRef.current.scrollBy({
|
||||
scrollContainerRef.current?.scrollBy({
|
||||
left: 120,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
@@ -87,7 +119,7 @@ const RequestTabs = () => {
|
||||
{collectionRequestTabs && collectionRequestTabs.length ? (
|
||||
<>
|
||||
<CollectionToolBar collection={activeCollection} />
|
||||
<div className="flex items-center pl-4">
|
||||
<div className="flex items-center pl-2">
|
||||
<ul role="tablist">
|
||||
{showChevrons ? (
|
||||
<li className="select-none short-tab" onClick={leftSlide}>
|
||||
@@ -103,36 +135,40 @@ const RequestTabs = () => {
|
||||
</div>
|
||||
</li> */}
|
||||
</ul>
|
||||
<ul role="tablist" style={{ maxWidth: maxTablistWidth }} ref={tabsRef}>
|
||||
{collectionRequestTabs && collectionRequestTabs.length
|
||||
? collectionRequestTabs.map((tab, index) => {
|
||||
return (
|
||||
<DraggableTab
|
||||
key={tab.uid}
|
||||
id={tab.uid}
|
||||
index={index}
|
||||
onMoveTab={(source, target) => {
|
||||
dispatch(reorderTabs({
|
||||
sourceUid: source,
|
||||
targetUid: target
|
||||
}));
|
||||
}}
|
||||
className={getTabClassname(tab, index)}
|
||||
onClick={() => handleClick(tab)}
|
||||
>
|
||||
<RequestTab
|
||||
collectionRequestTabs={collectionRequestTabs}
|
||||
tabIndex={index}
|
||||
<div className="tabs-scroll-container" style={{ maxWidth: maxTablistWidth }} ref={scrollContainerRef}>
|
||||
<ul role="tablist" ref={tabsRef}>
|
||||
{collectionRequestTabs && collectionRequestTabs.length
|
||||
? collectionRequestTabs.map((tab, index) => {
|
||||
return (
|
||||
<DraggableTab
|
||||
key={tab.uid}
|
||||
tab={tab}
|
||||
collection={activeCollection}
|
||||
folderUid={tab.folderUid}
|
||||
/>
|
||||
</DraggableTab>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ul>
|
||||
id={tab.uid}
|
||||
index={index}
|
||||
onMoveTab={(source, target) => {
|
||||
dispatch(reorderTabs({
|
||||
sourceUid: source,
|
||||
targetUid: target
|
||||
}));
|
||||
}}
|
||||
className={getTabClassname(tab, index)}
|
||||
onClick={() => handleClick(tab)}
|
||||
>
|
||||
<RequestTab
|
||||
collectionRequestTabs={collectionRequestTabs}
|
||||
tabIndex={index}
|
||||
key={tab.uid}
|
||||
tab={tab}
|
||||
collection={activeCollection}
|
||||
folderUid={tab.folderUid}
|
||||
hasOverflow={tabOverflowStates[tab.uid]}
|
||||
setHasOverflow={createSetHasOverflow(tab.uid)}
|
||||
/>
|
||||
</DraggableTab>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<ul role="tablist">
|
||||
{showChevrons ? (
|
||||
|
||||
@@ -104,6 +104,17 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
dragPreview(getEmptyImage(), { captureDraggingState: true });
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to show this item when its tab becomes active
|
||||
useEffect(() => {
|
||||
if (isTabForItemActive && ref.current) {
|
||||
try {
|
||||
ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
} catch (err) {
|
||||
// ignore scroll errors (some environments may not support smooth scrolling)
|
||||
}
|
||||
}
|
||||
}, [isTabForItemActive]);
|
||||
|
||||
const determineDropType = (monitor) => {
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
|
||||
@@ -30,10 +30,10 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
|
||||
.then(() => {
|
||||
dispatch(removeCollection(collectionUid))
|
||||
.then(() => {
|
||||
toast.success('Collection closed');
|
||||
toast.success('Collection removed from workspace');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while closing the collection'));
|
||||
.catch(() => toast.error('An error occurred while removing the collection'));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to save requests!');
|
||||
@@ -49,13 +49,13 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
|
||||
}));
|
||||
});
|
||||
|
||||
// Then close the collection
|
||||
// Then remove the collection
|
||||
dispatch(removeCollection(collectionUid))
|
||||
.then(() => {
|
||||
toast.success('Collection closed');
|
||||
toast.success('Collection removed from workspace');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while closing the collection'));
|
||||
.catch(() => toast.error('An error occurred while removing the collection'));
|
||||
};
|
||||
|
||||
if (!currentDrafts.length) {
|
||||
@@ -65,9 +65,9 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Close Collection"
|
||||
confirmText="Save and Close"
|
||||
cancelText="Close without saving"
|
||||
title="Remove Collection"
|
||||
confirmText="Save and Remove"
|
||||
cancelText="Remove without saving"
|
||||
handleCancel={onClose}
|
||||
disableEscapeKey={true}
|
||||
disableCloseOnOutsideClick={true}
|
||||
@@ -103,7 +103,7 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
|
||||
<div className="flex justify-between mt-6">
|
||||
<div>
|
||||
<button className="btn btn-sm btn-danger" onClick={handleDiscardAll}>
|
||||
Discard and Close
|
||||
Discard and Remove
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
@@ -111,7 +111,7 @@ const ConfirmCollectionCloseDrafts = ({ onClose, collection, collectionUid }) =>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleSaveAll}>
|
||||
{currentDrafts.length > 1 ? 'Save All and Close' : 'Save and Close'}
|
||||
{currentDrafts.length > 1 ? 'Save All and Remove' : 'Save and Remove'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,32 +20,41 @@ const RemoveCollection = ({ onClose, collectionUid }) => {
|
||||
}, [collection]);
|
||||
|
||||
const onConfirm = () => {
|
||||
if (!collection) {
|
||||
toast.error('Collection not found');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
dispatch(removeCollection(collection.uid))
|
||||
.then(() => {
|
||||
toast.success('Collection closed');
|
||||
toast.success('Collection removed from workspace');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while closing the collection'));
|
||||
.catch(() => toast.error('An error occurred while removing the collection'));
|
||||
};
|
||||
|
||||
if (!collection) {
|
||||
return <div>Collection not found</div>;
|
||||
}
|
||||
|
||||
// If there are drafts, show the draft confirmation modal
|
||||
if (drafts.length > 0) {
|
||||
return <ConfirmCollectionCloseDrafts onClose={onClose} collection={collection} collectionUid={collectionUid} />;
|
||||
}
|
||||
|
||||
// Otherwise, show the standard close confirmation modal
|
||||
// Otherwise, show the standard remove confirmation modal
|
||||
return (
|
||||
<Modal size="sm" title="Close Collection" confirmText="Close" handleConfirm={onConfirm} handleCancel={onClose}>
|
||||
<Modal size="sm" title="Remove Collection" confirmText="Remove" handleConfirm={onConfirm} handleCancel={onClose}>
|
||||
<div className="flex items-center">
|
||||
<IconFiles size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2 mr-4 font-medium">{collection.name}</span>
|
||||
</div>
|
||||
<div className="break-words text-xs mt-1">{collection.pathname}</div>
|
||||
<div className="mt-4">
|
||||
Are you sure you want to close collection <span className="font-medium">{collection.name}</span> in Bruno?
|
||||
Are you sure you want to remove collection <span className="font-medium">{collection.name}</span> from this workspace?
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
It will still be available in the file system at the above location and can be re-opened later.
|
||||
<div className="mt-4 text-muted">
|
||||
The collection files will remain on disk and can be re-added to this or another workspace later.
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ const Wrapper = styled.div`
|
||||
height: 1.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
padding-left: 8px;
|
||||
padding-left: 4px;
|
||||
border: ${(props) => props.theme.dragAndDrop.borderStyle} transparent;
|
||||
|
||||
.rotate-90 {
|
||||
|
||||
@@ -318,6 +318,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
className="dropdown-item"
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
ensureCollectionIsMounted();
|
||||
setShowNewRequestModal(true);
|
||||
}}
|
||||
>
|
||||
@@ -330,6 +331,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
className="dropdown-item"
|
||||
onClick={(_e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
ensureCollectionIsMounted();
|
||||
setShowNewFolderModal(true);
|
||||
}}
|
||||
>
|
||||
@@ -448,7 +450,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
<span className="dropdown-icon">
|
||||
<IconX size={16} strokeWidth={2} />
|
||||
</span>
|
||||
Close
|
||||
Remove
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
margin: 4px 10px 8px 10px;
|
||||
position: relative;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0 32px 0 32px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border-color: ${(props) => props.theme.sidebar.muted}40;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
border-color: ${(props) => props.theme.sidebar.muted}80;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IconSearch, IconX } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollectionSearch = ({ searchText, setSearchText }) => {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search requests..."
|
||||
id="search"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value.toLowerCase())}
|
||||
/>
|
||||
{searchText !== '' && (
|
||||
<div className="clear-icon" onClick={() => setSearchText('')}>
|
||||
<IconX size={14} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionSearch;
|
||||
@@ -1,24 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
.collections-badge {
|
||||
margin-inline: 0.5rem;
|
||||
background-color: ${(props) => props.theme.sidebar.badge.bg};
|
||||
border-radius: 5px;
|
||||
|
||||
.caret {
|
||||
margin-left: 0.25rem;
|
||||
color: rgb(140, 140, 140);
|
||||
fill: rgb(140, 140, 140);
|
||||
}
|
||||
|
||||
.collections-header-actions {
|
||||
.collection-action-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconArrowsSort, IconFolders, IconSortAscendingLetters, IconSortDescendingLetters } from '@tabler/icons';
|
||||
import CloseAllIcon from 'components/Icons/CloseAll';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import RemoveCollectionsModal from '../RemoveCollectionsModal';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollectionsHeader = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const { collectionSortOrder } = useSelector((state) => state.collections);
|
||||
const [collectionsToClose, setCollectionsToClose] = useState([]);
|
||||
|
||||
const sortCollectionOrder = () => {
|
||||
let order;
|
||||
switch (collectionSortOrder) {
|
||||
case 'default':
|
||||
order = 'alphabetical';
|
||||
break;
|
||||
case 'alphabetical':
|
||||
order = 'reverseAlphabetical';
|
||||
break;
|
||||
case 'reverseAlphabetical':
|
||||
order = 'default';
|
||||
break;
|
||||
}
|
||||
dispatch(sortCollections({ order }));
|
||||
};
|
||||
|
||||
let sortIcon;
|
||||
if (collectionSortOrder === 'default') {
|
||||
sortIcon = <IconArrowsSort size={18} strokeWidth={1.5} />;
|
||||
} else if (collectionSortOrder === 'alphabetical') {
|
||||
sortIcon = <IconSortAscendingLetters size={18} strokeWidth={1.5} />;
|
||||
} else {
|
||||
sortIcon = <IconSortDescendingLetters size={18} strokeWidth={1.5} />;
|
||||
}
|
||||
|
||||
const selectAllCollectionsToClose = () => {
|
||||
setCollectionsToClose(collections.map((c) => c.uid));
|
||||
};
|
||||
|
||||
const clearCollectionsToClose = () => {
|
||||
setCollectionsToClose([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="collections-badge flex items-center justify-between px-2 mt-2 relative">
|
||||
<div className="flex items-center py-1 select-none">
|
||||
<span className="mr-2">
|
||||
<IconFolders size={18} strokeWidth={1.5} />
|
||||
</span>
|
||||
<span>Collections</span>
|
||||
</div>
|
||||
{collections.length >= 1 && (
|
||||
<div className="flex items-center collections-header-actions">
|
||||
<button
|
||||
className="mr-1 collection-action-button"
|
||||
onClick={selectAllCollectionsToClose}
|
||||
aria-label="Close all collections"
|
||||
title="Close all collections"
|
||||
data-testid="close-all-collections-button"
|
||||
>
|
||||
<CloseAllIcon size={18} strokeWidth={1.5} className="cursor-pointer" />
|
||||
</button>
|
||||
<button
|
||||
className="collection-action-button"
|
||||
onClick={() => sortCollectionOrder()}
|
||||
aria-label="Sort collections"
|
||||
title="Sort collections"
|
||||
>
|
||||
{sortIcon}
|
||||
</button>
|
||||
{collectionsToClose.length > 0 && (
|
||||
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionsHeader;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useTheme } from '../../../../providers/Theme';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -17,6 +17,9 @@ const CreateOrOpenCollection = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
dispatch(openCollection()).catch(
|
||||
(err) => {
|
||||
@@ -42,7 +45,11 @@ const CreateOrOpenCollection = () => {
|
||||
|
||||
return (
|
||||
<StyledWrapper className="px-2 mt-4">
|
||||
{createCollectionModalOpen ? <CreateCollection onClose={() => setCreateCollectionModalOpen(false)} /> : null}
|
||||
{createCollectionModalOpen ? (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="text-xs text-center">
|
||||
<div>No collections found.</div>
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
span.close-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding-top: 4px;
|
||||
|
||||
&:hover .collections-badge .collections-header-actions .collection-action-button {
|
||||
opacity: 1;
|
||||
.collections-list {
|
||||
min-height: 0;
|
||||
padding: 0 4px;
|
||||
padding-top: 4px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${(props) => props.theme.scrollbar.color};
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: ${(props) => props.theme.scrollbar.color};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import {
|
||||
IconSearch,
|
||||
IconX
|
||||
} from '@tabler/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import CreateCollection from '../CreateCollection';
|
||||
import Collection from './Collection';
|
||||
import CollectionsHeader from './CollectionsHeader';
|
||||
import CreateOrOpenCollection from './CreateOrOpenCollection';
|
||||
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';
|
||||
|
||||
const Collections = () => {
|
||||
const Collections = ({ showSearch }) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
|
||||
if (!collections || !collections.length) {
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid) || workspaces.find((w) => w.type === 'default');
|
||||
|
||||
const workspaceCollections = useMemo(() => {
|
||||
if (!activeWorkspace) return [];
|
||||
return collections.filter((c) =>
|
||||
activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))
|
||||
);
|
||||
}, [activeWorkspace, collections]);
|
||||
|
||||
if (!workspaceCollections || !workspaceCollections.length) {
|
||||
return (
|
||||
<StyledWrapper data-testid="collections">
|
||||
<CollectionsHeader />
|
||||
<StyledWrapper>
|
||||
<CreateOrOpenCollection />
|
||||
</StyledWrapper>
|
||||
);
|
||||
@@ -26,46 +33,19 @@ const Collections = () => {
|
||||
|
||||
return (
|
||||
<StyledWrapper data-testid="collections">
|
||||
{createCollectionModalOpen ? <CreateCollection onClose={() => setCreateCollectionModalOpen(false)} /> : null}
|
||||
|
||||
<CollectionsHeader />
|
||||
|
||||
<div className="mt-4 relative collection-filter px-2">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500">
|
||||
<IconSearch size={16} strokeWidth={1.5} />
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
placeholder="Search requests …"
|
||||
id="search"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="block w-full pl-7 py-1"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value.toLowerCase())}
|
||||
{createCollectionModalOpen ? (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
{searchText !== '' && (
|
||||
<div className="absolute inset-y-0 right-0 pr-4 flex items-center">
|
||||
<span
|
||||
className="close-icon"
|
||||
onClick={() => {
|
||||
setSearchText('');
|
||||
}}
|
||||
>
|
||||
<IconX size={16} strokeWidth={1.5} className="cursor-pointer" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-4 flex flex-col overflow-hidden hover:overflow-y-auto absolute top-32 bottom-0 left-0 right-0">
|
||||
{collections && collections.length
|
||||
? collections.map((c) => {
|
||||
{showSearch && (
|
||||
<CollectionSearch searchText={searchText} setSearchText={setSearchText} />
|
||||
)}
|
||||
|
||||
<div className="collections-list flex flex-col flex-1 overflow-hidden hover:overflow-y-auto">
|
||||
{workspaceCollections && workspaceCollections.length
|
||||
? workspaceCollections.map((c) => {
|
||||
return (
|
||||
<Collection searchText={searchText} collection={c} key={c.uid} />
|
||||
);
|
||||
|
||||
@@ -2,8 +2,8 @@ import React, { useRef, useEffect, forwardRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import path from 'path';
|
||||
import { browseDirectory, createCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
@@ -19,22 +19,34 @@ import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import get from 'lodash/get';
|
||||
|
||||
const CreateCollection = ({ onClose }) => {
|
||||
const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const workspaces = useSelector((state) => state.workspaces?.workspaces || []);
|
||||
const workspaceUid = useSelector((state) => state.workspaces?.activeWorkspaceUid);
|
||||
const [isEditing, toggleEditing] = useState(false);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
|
||||
const [showExternalLocation, setShowExternalLocation] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid);
|
||||
const isDefaultWorkspace = activeWorkspace?.type === 'default';
|
||||
|
||||
const hideLocationInput = activeWorkspace && activeWorkspace.type !== 'default' && !!activeWorkspace?.pathname;
|
||||
|
||||
const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultCollectionLocation', '') : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
|
||||
|
||||
const shouldShowAccordion = workspaceUid && hideLocationInput && !isDefaultWorkspace;
|
||||
const actuallyHideLocationInput = hideLocationInput && !showExternalLocation && !isDefaultWorkspace;
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
collectionName: '',
|
||||
collectionFolderName: '',
|
||||
collectionLocation: defaultLocation,
|
||||
collectionLocation: defaultLocation || '',
|
||||
format: 'yml'
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
@@ -50,31 +62,53 @@ const CreateCollection = ({ onClose }) => {
|
||||
return isValid ? true : this.createError({ message: validateNameError(value) });
|
||||
})
|
||||
.required('folder name is required'),
|
||||
collectionLocation: Yup.string().min(1, 'location is required').required('location is required'),
|
||||
collectionLocation: actuallyHideLocationInput
|
||||
? Yup.string() // Optional for workspaces when not using external location
|
||||
: Yup.string().min(1, 'location is required').required('location is required'),
|
||||
format: Yup.string().oneOf(['bru', 'yml'], 'invalid format').required('format is required')
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation, values.format))
|
||||
.then(() => {
|
||||
toast.success('Collection created!');
|
||||
dispatch(toggleSidebarCollapse());
|
||||
onClose();
|
||||
})
|
||||
.catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
|
||||
onSubmit: async (values) => {
|
||||
try {
|
||||
const currentWorkspace = workspaces.find((w) => w.uid === workspaceUid);
|
||||
const useExternalLocation = workspaceUid && showExternalLocation && values.collectionLocation;
|
||||
|
||||
let collectionLocation = values.collectionLocation;
|
||||
if (workspaceUid && !useExternalLocation && currentWorkspace && currentWorkspace.type !== 'default') {
|
||||
collectionLocation = path.join(currentWorkspace.pathname, 'collections');
|
||||
}
|
||||
|
||||
await dispatch(createCollection(values.collectionName,
|
||||
values.collectionFolderName,
|
||||
collectionLocation,
|
||||
{ format: values.format }));
|
||||
|
||||
if (useExternalLocation && currentWorkspace) {
|
||||
const { ipcRenderer } = window;
|
||||
const collectionPath = path.join(values.collectionLocation, values.collectionFolderName);
|
||||
const workspaceCollection = {
|
||||
name: values.collectionName,
|
||||
path: collectionPath
|
||||
};
|
||||
await ipcRenderer.invoke('renderer:add-collection-to-workspace', currentWorkspace.pathname, workspaceCollection);
|
||||
}
|
||||
|
||||
toast.success('Collection created!');
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const browse = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
// When the user closes the dialog without selecting anything dirPath will be false
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('collectionLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
.catch(() => {
|
||||
formik.setFieldValue('collectionLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -84,8 +118,6 @@ const CreateCollection = ({ onClose }) => {
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = () => formik.handleSubmit();
|
||||
|
||||
const AdvancedOptions = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex mr-2 text-link cursor-pointer items-center">
|
||||
@@ -129,43 +161,47 @@ const CreateCollection = ({ onClose }) => {
|
||||
<div className="text-red-500">{formik.errors.collectionName}</div>
|
||||
) : null}
|
||||
|
||||
<label htmlFor="collection-location" className="font-medium mt-3 flex items-center">
|
||||
Location
|
||||
<Help>
|
||||
<p>
|
||||
Bruno stores your collections on your computer's filesystem.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Choose the location where you want to store this collection.
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<input
|
||||
id="collection-location"
|
||||
type="text"
|
||||
name="collectionLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={formik.values.collectionLocation || ''}
|
||||
onClick={browse}
|
||||
onChange={(e) => {
|
||||
formik.setFieldValue('collectionLocation', e.target.value);
|
||||
}}
|
||||
/>
|
||||
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.collectionLocation}</div>
|
||||
) : null}
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline"
|
||||
onClick={browse}
|
||||
>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
{!actuallyHideLocationInput && (
|
||||
<>
|
||||
<label htmlFor="collection-location" className="font-medium mt-3 flex items-center">
|
||||
Location
|
||||
<Help>
|
||||
<p>
|
||||
Bruno stores your collections on your computer's filesystem.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Choose the location where you want to store this collection.
|
||||
</p>
|
||||
</Help>
|
||||
</label>
|
||||
<input
|
||||
id="collection-location"
|
||||
type="text"
|
||||
name="collectionLocation"
|
||||
className="block textbox mt-2 w-full cursor-pointer"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={formik.values.collectionLocation || ''}
|
||||
onClick={browse}
|
||||
onChange={(e) => {
|
||||
formik.setFieldValue('collectionLocation', e.target.value);
|
||||
}}
|
||||
/>
|
||||
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
|
||||
<div className="text-red-500">{formik.errors.collectionLocation}</div>
|
||||
) : null}
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className="text-link cursor-pointer hover:underline"
|
||||
onClick={browse}
|
||||
>
|
||||
Browse
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{formik.values.collectionName?.trim()?.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -257,6 +293,18 @@ const CreateCollection = ({ onClose }) => {
|
||||
<div className="flex justify-between items-center mt-8 bruno-modal-footer">
|
||||
<div className="flex advanced-options">
|
||||
<Dropdown onCreate={onDropdownCreate} icon={<AdvancedOptions />} placement="bottom-start">
|
||||
{shouldShowAccordion && (
|
||||
<div
|
||||
className="dropdown-item"
|
||||
key="create-external-location"
|
||||
onClick={(e) => {
|
||||
dropdownTippyRef.current.hide();
|
||||
setShowExternalLocation(!showExternalLocation);
|
||||
}}
|
||||
>
|
||||
{showExternalLocation ? 'Use Default Location' : 'Create in External Location'}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="dropdown-item"
|
||||
key="show-file-format"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { IconFolder } from '@tabler/icons';
|
||||
import { closeWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
|
||||
const CloseWorkspace = ({ workspaceUid, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { workspaces } = useSelector((state) => state.workspaces);
|
||||
const workspace = workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
const onConfirm = async () => {
|
||||
try {
|
||||
if (!workspace) {
|
||||
toast.error('Workspace not found');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (workspace.type === 'default') {
|
||||
toast.error('Cannot close the default workspace');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatch(closeWorkspaceAction(workspace.uid));
|
||||
toast.success('Workspace closed');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error closing workspace:', error);
|
||||
toast.error('An error occurred while closing the workspace');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Close Workspace"
|
||||
confirmText="Close"
|
||||
handleConfirm={onConfirm}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<IconFolder size={18} strokeWidth={1.5} />
|
||||
<span className="ml-2 mr-4 font-semibold">{workspace?.name}</span>
|
||||
</div>
|
||||
{workspace?.pathname && (
|
||||
<div className="break-words text-xs mt-1">{workspace.pathname}</div>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
Are you sure you want to close workspace <span className="font-semibold">{workspace?.name}</span>?
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
It will still be available in the file system at the above location and can be re-opened later.
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CloseWorkspace;
|
||||
@@ -0,0 +1,110 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
padding: 8px 4px 6px 10px;
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Section Title (single view mode) - with separator */
|
||||
&.single-view {
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
|
||||
/* Section Title (single view mode) */
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 0;
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
}
|
||||
}
|
||||
|
||||
/* View Tabs (multi-view mode) */
|
||||
.view-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
border-radius: 6px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.view-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
color: ${(props) => props.theme.sidebar.color};
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 280px) {
|
||||
span {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Header Actions */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.dropdown?.hoverBg || props.theme.sidebar?.collection?.item?.hoverBg};
|
||||
color: ${(props) => props.theme.dropdown?.mutedText || props.theme.text?.muted || '#888'};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState, forwardRef, useRef, useMemo, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IconPlus, IconChevronDown, IconCheck, IconFolder, IconPin, IconPinned } from '@tabler/icons';
|
||||
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { switchWorkspace, openWorkspaceDialog } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
|
||||
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
|
||||
import { toTitleCase } from 'utils/common/index';
|
||||
|
||||
const WorkspaceSelector = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
return sortWorkspaces(workspaces, preferences);
|
||||
}, [workspaces, preferences]);
|
||||
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
|
||||
|
||||
const dropdownTippyRef = useRef();
|
||||
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
|
||||
|
||||
const WorkspaceName = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="workspace-name-container" onClick={() => setShowDropdown(!showDropdown)}>
|
||||
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
|
||||
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
dispatch(switchWorkspace(workspaceUid));
|
||||
setShowDropdown(false);
|
||||
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
|
||||
};
|
||||
|
||||
const handleOpenWorkspace = async () => {
|
||||
setShowDropdown(false);
|
||||
try {
|
||||
await dispatch(openWorkspaceDialog());
|
||||
toast.success('Workspace opened successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to open workspace');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateWorkspace = () => {
|
||||
setShowDropdown(false);
|
||||
setCreateWorkspaceModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePinWorkspace = useCallback((workspaceUid, e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const newPreferences = toggleWorkspacePin(workspaceUid, preferences);
|
||||
dispatch(savePreferences(newPreferences));
|
||||
}, [dispatch, preferences]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{createWorkspaceModalOpen && (
|
||||
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
onCreate={onDropdownCreate}
|
||||
icon={<WorkspaceName />}
|
||||
placement="bottom-start"
|
||||
style="new"
|
||||
visible={showDropdown}
|
||||
onClickOutside={() => setShowDropdown(false)}
|
||||
>
|
||||
{sortedWorkspaces.map((workspace) => {
|
||||
const isActive = workspace.uid === activeWorkspaceUid;
|
||||
const isPinned = preferences?.workspaces?.pinnedWorkspaceUids?.includes(workspace.uid);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.uid}
|
||||
className={`dropdown-item workspace-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleWorkspaceSwitch(workspace.uid)}
|
||||
>
|
||||
<span className="workspace-name">{toTitleCase(workspace.name)}</span>
|
||||
<div className="workspace-actions">
|
||||
{workspace.type !== 'default' && (
|
||||
<button
|
||||
className={`pin-btn ${isPinned ? 'pinned' : ''}`}
|
||||
onClick={(e) => handlePinWorkspace(workspace.uid, e)}
|
||||
title={isPinned ? 'Unpin workspace' : 'Pin workspace'}
|
||||
>
|
||||
{isPinned ? (
|
||||
<IconPinned size={14} stroke={1.5} />
|
||||
) : (
|
||||
<IconPin size={14} stroke={1.5} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="label-item border-top">Workspaces</div>
|
||||
|
||||
<div className="dropdown-item" onClick={handleCreateWorkspace}>
|
||||
<IconPlus size={16} stroke={1.5} className="icon" />
|
||||
Create workspace
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={handleOpenWorkspace}>
|
||||
<IconFolder size={16} stroke={1.5} className="icon" />
|
||||
Open workspace
|
||||
</div>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceSelector;
|
||||
337
packages/bruno-app/src/components/Sidebar/SidebarHeader/index.js
Normal file
337
packages/bruno-app/src/components/Sidebar/SidebarHeader/index.js
Normal file
@@ -0,0 +1,337 @@
|
||||
import {
|
||||
IconArrowsSort,
|
||||
IconBox,
|
||||
IconDeviceDesktop,
|
||||
IconDotsVertical,
|
||||
IconDownload,
|
||||
IconFolder,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSortAscendingLetters,
|
||||
IconSortDescendingLetters,
|
||||
IconSquareX,
|
||||
IconTrash
|
||||
} from '@tabler/icons';
|
||||
import { useRef, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
|
||||
import { importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
|
||||
import RemoveCollectionsModal from '../Collections/RemoveCollectionsModal/index';
|
||||
import CreateCollection from '../CreateCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const VIEW_TABS = [
|
||||
{ id: 'collections', label: 'Collections', icon: IconBox }
|
||||
];
|
||||
|
||||
const SidebarHeader = ({ setShowSearch, activeView = 'collections', onViewChange }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
|
||||
|
||||
// Get collection sort order
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const { collectionSortOrder } = useSelector((state) => state.collections);
|
||||
const [collectionsToClose, setCollectionsToClose] = useState([]);
|
||||
|
||||
const [importData, setImportData] = useState(null);
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
|
||||
if (activeWorkspace && activeWorkspace.type !== 'default') {
|
||||
dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
|
||||
.catch((err) => {
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
} else {
|
||||
setImportData({ rawData, type });
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation))
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
toast.success('Collection imported successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const addDropdownTippyRef = useRef();
|
||||
const onAddDropdownCreate = (ref) => (addDropdownTippyRef.current = ref);
|
||||
|
||||
const actionsDropdownTippyRef = useRef();
|
||||
const onActionsDropdownCreate = (ref) => (actionsDropdownTippyRef.current = ref);
|
||||
|
||||
const handleToggleSearch = () => {
|
||||
if (setShowSearch) {
|
||||
setShowSearch((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortCollections = () => {
|
||||
let order;
|
||||
switch (collectionSortOrder) {
|
||||
case 'default':
|
||||
order = 'alphabetical';
|
||||
break;
|
||||
case 'alphabetical':
|
||||
order = 'reverseAlphabetical';
|
||||
break;
|
||||
case 'reverseAlphabetical':
|
||||
order = 'default';
|
||||
break;
|
||||
default:
|
||||
order = 'default';
|
||||
break;
|
||||
}
|
||||
dispatch(sortCollections({ order }));
|
||||
};
|
||||
|
||||
const getSortIcon = () => {
|
||||
switch (collectionSortOrder) {
|
||||
case 'alphabetical':
|
||||
return IconSortDescendingLetters;
|
||||
case 'reverseAlphabetical':
|
||||
return IconArrowsSort;
|
||||
default:
|
||||
return IconSortAscendingLetters;
|
||||
}
|
||||
};
|
||||
|
||||
const getSortLabel = () => {
|
||||
switch (collectionSortOrder) {
|
||||
case 'alphabetical':
|
||||
return 'Sort Z-A';
|
||||
case 'reverseAlphabetical':
|
||||
return 'Clear sort';
|
||||
default:
|
||||
return 'Sort A-Z';
|
||||
}
|
||||
};
|
||||
|
||||
const selectAllCollectionsToClose = () => {
|
||||
setCollectionsToClose(collections.map((c) => c.uid));
|
||||
};
|
||||
|
||||
const clearCollectionsToClose = () => {
|
||||
setCollectionsToClose([]);
|
||||
};
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
const options = {};
|
||||
if (activeWorkspace?.pathname) {
|
||||
options.workspaceId = activeWorkspace.pathname;
|
||||
}
|
||||
|
||||
dispatch(openCollection(options)).catch((err) => {
|
||||
toast.error('An error occurred while opening the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const renderModals = () => (
|
||||
<>
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
handleSubmit={handleImportCollection}
|
||||
/>
|
||||
)}
|
||||
{importCollectionLocationModalOpen && importData && (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const isSingleView = VIEW_TABS.length === 1;
|
||||
|
||||
// Render Collections-specific actions
|
||||
const renderCollectionsActions = () => (
|
||||
<>
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={handleToggleSearch}
|
||||
title="Search requests"
|
||||
>
|
||||
<IconSearch size={14} stroke={1.5} />
|
||||
</button>
|
||||
{/* Add/Create dropdown */}
|
||||
<Dropdown
|
||||
onCreate={onAddDropdownCreate}
|
||||
icon={(
|
||||
<button className="action-button plus-icon-button" title="Add new">
|
||||
<IconPlus size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
placement="bottom-end"
|
||||
style="new"
|
||||
>
|
||||
<div className="label-item">Collections</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
setCreateCollectionModalOpen(true);
|
||||
addDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconPlus size={16} stroke={1.5} className="icon" />
|
||||
Create collection
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
addDropdownTippyRef.current?.hide();
|
||||
setImportCollectionModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<IconDownload size={16} stroke={1.5} className="icon" />
|
||||
Import collection
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
handleOpenCollection();
|
||||
addDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
>
|
||||
<IconFolder size={16} stroke={1.5} className="icon" />
|
||||
Open collection
|
||||
</div>
|
||||
|
||||
</Dropdown>
|
||||
|
||||
{/* Actions dropdown (sort, close all, etc.) */}
|
||||
<Dropdown
|
||||
onCreate={onActionsDropdownCreate}
|
||||
icon={(
|
||||
<button className="action-button" title="More actions">
|
||||
<IconDotsVertical size={14} stroke={1.5} />
|
||||
</button>
|
||||
)}
|
||||
placement="bottom-end"
|
||||
style="new"
|
||||
>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
handleSortCollections();
|
||||
actionsDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
aria-label="Sort collections"
|
||||
title="Sort collections"
|
||||
data-testid="sort-collections-button"
|
||||
>
|
||||
{(() => {
|
||||
const SortIcon = getSortIcon();
|
||||
return <SortIcon size={16} stroke={1.5} className="icon" />;
|
||||
})()}
|
||||
{getSortLabel()}
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={() => {
|
||||
selectAllCollectionsToClose();
|
||||
actionsDropdownTippyRef.current?.hide();
|
||||
}}
|
||||
aria-label="Close all collections"
|
||||
title="Close all collections"
|
||||
data-testid="close-all-collections-button"
|
||||
>
|
||||
<IconSquareX size={16} stroke={1.5} className="icon" />
|
||||
Close all
|
||||
</div>
|
||||
</Dropdown>
|
||||
|
||||
{collectionsToClose.length > 0 && (
|
||||
<RemoveCollectionsModal collectionUids={collectionsToClose} onClose={clearCollectionsToClose} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Render Second Tab-specific actions
|
||||
const renderSecondTabActions = () => (
|
||||
<>
|
||||
{/* Add second tab actions here */}
|
||||
</>
|
||||
);
|
||||
|
||||
// Render the view switcher - either tabs or single title
|
||||
const renderViewSwitcher = () => {
|
||||
if (isSingleView) {
|
||||
// Single view - just show the title
|
||||
const tab = VIEW_TABS[0];
|
||||
const TabIcon = tab.icon;
|
||||
return (
|
||||
<div className="section-title">
|
||||
<TabIcon size={14} stroke={1.5} />
|
||||
<span>{tab.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple views - show segmented tabs
|
||||
return (
|
||||
<div className="view-tabs">
|
||||
{VIEW_TABS.map((tab) => {
|
||||
const TabIcon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`view-tab ${activeView === tab.id ? 'active' : ''}`}
|
||||
onClick={() => onViewChange?.(tab.id)}
|
||||
title={tab.label}
|
||||
>
|
||||
<TabIcon size={14} stroke={1.5} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className={isSingleView ? 'single-view' : ''}>
|
||||
{renderModals()}
|
||||
<div className="sidebar-header">
|
||||
{renderViewSwitcher()}
|
||||
|
||||
{/* Action Buttons - Context Sensitive */}
|
||||
<div className="header-actions">
|
||||
{activeView === 'collections' ? renderCollectionsActions() : renderSecondTabActions()}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarHeader;
|
||||
@@ -28,18 +28,6 @@ const Wrapper = styled.div`
|
||||
top: -0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.collection-filter {
|
||||
input {
|
||||
border: ${(props) => props.theme.sidebar.search.border};
|
||||
border-radius: 2px;
|
||||
background-color: ${(props) => props.theme.sidebar.search.bg};
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.sidebar-drag-handle {
|
||||
@@ -65,6 +53,15 @@ const Wrapper = styled.div`
|
||||
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.activeBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.second-tab-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: ${(props) => props.theme.sidebar.muted};
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.collection-dropdown {
|
||||
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.tippy-box {
|
||||
top: -0.5rem;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,150 +0,0 @@
|
||||
import toast from 'react-hot-toast';
|
||||
import Bruno from 'components/Bruno';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import CreateCollection from '../CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
|
||||
import { IconDots, IconPlus, IconFolder, IconDownload, IconDeviceDesktop } from '@tabler/icons';
|
||||
import { useState, forwardRef, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { showHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { multiLineMsg } from 'utils/common';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const TitleBar = () => {
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
const [importData, setImportData] = useState(null);
|
||||
const dispatch = useDispatch();
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
setImportData({ rawData, type });
|
||||
setImportCollectionModalOpen(false);
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation))
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
toast.success('Collection imported successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error(multiLineMsg('An error occurred while importing the collection.', formatIpcError(err)));
|
||||
});
|
||||
};
|
||||
|
||||
const menuDropdownTippyRef = useRef();
|
||||
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
|
||||
const MenuIcon = forwardRef((props, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="dropdown-icon cursor-pointer">
|
||||
<IconDots size={22} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const handleTitleClick = () => dispatch(showHomePage());
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
dispatch(openCollection()).catch(
|
||||
(err) => {
|
||||
console.log(err);
|
||||
toast.error('An error occurred while opening the collection');
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const openDevTools = () => {
|
||||
ipcRenderer.invoke('renderer:open-devtools');
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="px-2 py-2">
|
||||
{createCollectionModalOpen ? <CreateCollection onClose={() => setCreateCollectionModalOpen(false)} /> : null}
|
||||
{importCollectionModalOpen ? (
|
||||
<ImportCollection onClose={() => setImportCollectionModalOpen(false)} handleSubmit={handleImportCollection} />
|
||||
) : null}
|
||||
{importCollectionLocationModalOpen && importData ? (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center">
|
||||
<button className="bruno-logo flex items-center gap-2 font-medium" onClick={handleTitleClick}>
|
||||
<span aria-hidden>
|
||||
<Bruno width={30} />
|
||||
</span>
|
||||
bruno
|
||||
</button>
|
||||
<div className="collection-dropdown flex flex-grow items-center justify-end">
|
||||
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
|
||||
<div className="label-item">Collections</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
setCreateCollectionModalOpen(true);
|
||||
menuDropdownTippyRef.current.hide();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconPlus size={16} strokeWidth={2} />
|
||||
</span>
|
||||
Create Collection
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
handleOpenCollection();
|
||||
menuDropdownTippyRef.current.hide();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconFolder size={16} strokeWidth={2} />
|
||||
</span>
|
||||
Open
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
setImportCollectionModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconDownload size={16} strokeWidth={2} />
|
||||
</span>
|
||||
Import
|
||||
</div>
|
||||
<div className="dropdown-separator"></div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
menuDropdownTippyRef.current.hide();
|
||||
openDevTools();
|
||||
}}
|
||||
>
|
||||
<span className="dropdown-icon">
|
||||
<IconDeviceDesktop size={16} strokeWidth={2} />
|
||||
</span>
|
||||
Devtools
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleBar;
|
||||
@@ -1,4 +1,4 @@
|
||||
import TitleBar from './TitleBar';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import Collections from './Collections';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateLeftSidebarWidth, updateIsDragging } from 'providers/ReduxStore/slices/app';
|
||||
|
||||
const MIN_LEFT_SIDEBAR_WIDTH = 221;
|
||||
const MIN_LEFT_SIDEBAR_WIDTH = 220;
|
||||
const MAX_LEFT_SIDEBAR_WIDTH = 600;
|
||||
|
||||
const Sidebar = () => {
|
||||
@@ -14,6 +14,8 @@ const Sidebar = () => {
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const [asideWidth, setAsideWidth] = useState(leftSidebarWidth);
|
||||
const lastWidthRef = useRef(leftSidebarWidth);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [activeView, setActiveView] = useState('collections'); // 'collections' or any other future tab
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [dragging, setDragging] = useState(false);
|
||||
@@ -80,9 +82,21 @@ const Sidebar = () => {
|
||||
<aside className="sidebar" style={{ width: currentWidth, transition: dragging ? 'none' : 'width 0.2s ease-in-out' }}>
|
||||
<div className="flex flex-row h-full w-full">
|
||||
<div className="flex flex-col w-full" style={{ width: asideWidth }}>
|
||||
<div className="flex flex-col flex-grow">
|
||||
<TitleBar />
|
||||
<Collections />
|
||||
<div className="flex flex-col flex-grow" style={{ minHeight: 0, overflow: 'hidden' }}>
|
||||
<SidebarHeader
|
||||
setShowSearch={setShowSearch}
|
||||
activeView={activeView}
|
||||
onViewChange={setActiveView}
|
||||
/>
|
||||
{activeView === 'collections' ? (
|
||||
<Collections showSearch={showSearch} />
|
||||
) : (
|
||||
<div className="second-tab-placeholder">
|
||||
<p className="text-center text-muted py-8 px-4 text-sm opacity-60">
|
||||
Second tab content will appear here
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import IconSidebarToggle from 'components/Icons/IconSidebarToggle';
|
||||
import Cookies from 'components/Cookies';
|
||||
import Notifications from 'components/Notifications';
|
||||
import Portal from 'components/Portal';
|
||||
import { showPreferences, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import { showPreferences } from 'providers/ReduxStore/slices/app';
|
||||
import { openConsole } from 'providers/ReduxStore/slices/logs';
|
||||
import { useApp } from 'providers/App';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
@@ -18,7 +18,6 @@ const StatusBar = () => {
|
||||
const dispatch = useDispatch();
|
||||
const preferencesOpen = useSelector((state) => state.app.showPreferences);
|
||||
const logs = useSelector((state) => state.logs.logs);
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const [cookiesOpen, setCookiesOpen] = useState(false);
|
||||
const { version } = useApp();
|
||||
|
||||
@@ -70,16 +69,6 @@ const StatusBar = () => {
|
||||
<div className="status-bar">
|
||||
<div className="status-bar-section">
|
||||
<div className="status-bar-group">
|
||||
<ToolHint text="Toggle Sidebar" toolhintId="Toggle Sidebar" place="top-start" offset={10}>
|
||||
<button
|
||||
className="status-bar-button"
|
||||
aria-label="Toggle Sidebar"
|
||||
onClick={() => dispatch(toggleSidebarCollapse())}
|
||||
>
|
||||
<IconSidebarToggle collapsed={sidebarCollapsed} size={16} strokeWidth={1.5} aria-hidden="true" />
|
||||
</button>
|
||||
</ToolHint>
|
||||
|
||||
<ToolHint text="Preferences" toolhintId="Preferences" place="top-start" offset={10}>
|
||||
<button
|
||||
className="status-bar-button preferences-button"
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.heading {
|
||||
color: ${(props) => props.theme.welcome.heading};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: ${(props) => props.theme.welcome.muted};
|
||||
}
|
||||
|
||||
.collection-options {
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.label {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keycap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px 6px;
|
||||
border: 1px solid ${(props) => props.theme.modal.input.border};
|
||||
border-radius: 4px;
|
||||
background: ${(props) =>
|
||||
props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
|
||||
import { IconBrandGithub, IconPlus, IconDownload, IconFolders, IconSpeakerphone, IconBook } from '@tabler/icons';
|
||||
|
||||
import Bruno from 'components/Bruno';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const Welcome = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const [importData, setImportData] = useState(null);
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
dispatch(openCollection())
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error(t('WELCOME.COLLECTION_OPEN_ERROR'));
|
||||
});
|
||||
};
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
setImportData({ rawData, type });
|
||||
setImportCollectionModalOpen(false);
|
||||
setImportCollectionLocationModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionLocation = (convertedCollection, collectionLocation) => {
|
||||
dispatch(importCollection(convertedCollection, collectionLocation))
|
||||
.then(() => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
setImportData(null);
|
||||
toast.success(t('WELCOME.COLLECTION_IMPORT_SUCCESS'));
|
||||
})
|
||||
.catch((err) => {
|
||||
setImportCollectionLocationModalOpen(false);
|
||||
console.error(err);
|
||||
toast.error(t('WELCOME.COLLECTION_IMPORT_ERROR'));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="pb-4 px-6 mt-6">
|
||||
{createCollectionModalOpen ? <CreateCollection onClose={() => setCreateCollectionModalOpen(false)} /> : null}
|
||||
{importCollectionModalOpen ? (
|
||||
<ImportCollection onClose={() => setImportCollectionModalOpen(false)} handleSubmit={handleImportCollection} />
|
||||
) : null}
|
||||
{importCollectionLocationModalOpen && importData ? (
|
||||
<ImportCollectionLocation
|
||||
rawData={importData.rawData}
|
||||
format={importData.type}
|
||||
onClose={() => setImportCollectionLocationModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionLocation}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div aria-hidden className="">
|
||||
<Bruno width={50} />
|
||||
</div>
|
||||
<div className="text-xl font-medium select-none">bruno</div>
|
||||
<div className="mt-4">{t('WELCOME.ABOUT_BRUNO')}</div>
|
||||
|
||||
<div className="uppercase font-medium heading mt-10">{t('COMMON.COLLECTIONS')}</div>
|
||||
<div className="mt-4 flex items-center collection-options select-none">
|
||||
<button
|
||||
className="flex items-center"
|
||||
onClick={() => setCreateCollectionModalOpen(true)}
|
||||
aria-label={t('WELCOME.CREATE_COLLECTION')}
|
||||
>
|
||||
<IconPlus aria-hidden size={18} strokeWidth={2} />
|
||||
<span className="label ml-2" id="create-collection" data-testid="create-collection">
|
||||
{t('WELCOME.CREATE_COLLECTION')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button className="flex items-center ml-6" onClick={handleOpenCollection} aria-label="Open Collection">
|
||||
<IconFolders aria-hidden size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">{t('WELCOME.OPEN_COLLECTION')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex items-center ml-6"
|
||||
onClick={() => setImportCollectionModalOpen(true)}
|
||||
aria-label={t('WELCOME.IMPORT_COLLECTION')}
|
||||
>
|
||||
<IconDownload aria-hidden size={18} strokeWidth={2} />
|
||||
<span className="label ml-2" id="import-collection">
|
||||
{t('WELCOME.IMPORT_COLLECTION')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="uppercase font-medium heading mt-10 pt-6">{t('WELCOME.LINKS')}</div>
|
||||
<div className="mt-4 flex flex-col collection-options select-none">
|
||||
<div className="flex items-center mt-2">
|
||||
<a
|
||||
href="https://docs.usebruno.com"
|
||||
aria-label="Read documentation"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<IconBook aria-hidden size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">{t('COMMON.DOCUMENTATION')}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<a
|
||||
href="https://github.com/usebruno/bruno/issues"
|
||||
aria-label="Report issues on GitHub"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<IconSpeakerphone aria-hidden size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">{t('COMMON.REPORT_ISSUES')}</span>
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center mt-2">
|
||||
<a
|
||||
href="https://github.com/usebruno/bruno"
|
||||
aria-label="Go to GitHub repository"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center"
|
||||
>
|
||||
<IconBrandGithub aria-hidden size={18} strokeWidth={2} />
|
||||
<span className="label ml-2">{t('COMMON.GITHUB')}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 select-none">
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART1')} <span className="keycap">⌘</span>{' '}<span className="keycap">K</span>{' '}
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART2')} <span className="keycap">Ctrl</span>{' '}<span className="keycap">K</span>{' '}
|
||||
{t('WELCOME.GLOBAL_SEARCH_TIP_PART3')}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
||||
134
packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js
Normal file
134
packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js
Normal file
@@ -0,0 +1,134 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.workspace-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace-rename-container {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
gap: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.workspace-name-input {
|
||||
padding: 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
outline: none;
|
||||
min-width: 200px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.bg.green};
|
||||
}
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.bg.red};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-error {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 16px;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
}
|
||||
|
||||
.workspace-menu-dropdown {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
|
||||
background: ${(props) => props.theme.bg.primary};
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
color: var(--color-tab-inactive);
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
border-bottom-color: ${(props) => props.theme.colors.border};
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom-color: ${(props) => props.theme.colors.text.yellow};
|
||||
color: ${(props) => props.theme.tabs.active.color};
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-action-buttons {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.workspace-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,154 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.collections-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
}
|
||||
|
||||
.collections-header {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: ${(props) => props.theme.workspace.collection.header.indentBorder};
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
&:has(.header-git) {
|
||||
grid-template-columns: 1fr 3fr 1fr 1.5fr;
|
||||
}
|
||||
|
||||
&:not(:has(.header-git)) {
|
||||
grid-template-columns: 1fr 3fr 1.5fr;
|
||||
}
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
font-weight: 600;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collections-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.collection-row {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 8px 16px;
|
||||
border-bottom: ${(props) => props.theme.workspace.collection.item.indentBorder};
|
||||
transition: background-color 0.15s ease;
|
||||
cursor: pointer;
|
||||
grid-template-columns: 1fr 3fr 1.5fr;
|
||||
|
||||
&:hover {
|
||||
background-color: ${(props) => props.theme.sidebar.bg};
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.row-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cell-name {
|
||||
.collection-icon {
|
||||
color: ${(props) => props.theme.workspace.accent};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collection-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.collection-name {
|
||||
font-weight: 400;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.collection-subtitle {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-location {
|
||||
.location-text {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
justify-content: flex-end;
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) => props.theme.listItem.hoverBg};
|
||||
|
||||
&.action-edit {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.action-share {
|
||||
color: #3B82F6;
|
||||
}
|
||||
|
||||
&.action-delete {
|
||||
color: #EF4444;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,351 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconBox, IconTrash, IconEdit, IconShare } from '@tabler/icons';
|
||||
import { removeCollectionFromWorkspaceAction, importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
|
||||
import ShareCollection from 'components/ShareCollection';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
|
||||
const WorkspaceCollections = ({ workspace, onImportCollection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const [collectionToRemove, setCollectionToRemove] = useState(null);
|
||||
const [renameCollectionModalOpen, setRenameCollectionModalOpen] = useState(false);
|
||||
const [shareCollectionModalOpen, setShareCollectionModalOpen] = useState(false);
|
||||
const [selectedCollectionUid, setSelectedCollectionUid] = useState(null);
|
||||
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
|
||||
const handleImportCollection = ({ rawData, type }) => {
|
||||
if (onImportCollection) {
|
||||
onImportCollection();
|
||||
return;
|
||||
}
|
||||
|
||||
setImportCollectionModalOpen(false);
|
||||
dispatch(importCollectionInWorkspace(rawData, workspace.uid, undefined, type))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const workspaceCollections = React.useMemo(() => {
|
||||
if (!workspace.collections || workspace.collections.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
workspace.collections.forEach((wc) => {
|
||||
const loadedCollection = collections.find((c) => normalizePath(c.pathname) === normalizePath(wc.path));
|
||||
|
||||
if (loadedCollection) {
|
||||
result.push({
|
||||
...loadedCollection,
|
||||
isGitBacked: !!wc.remote,
|
||||
gitRemoteUrl: wc.remote
|
||||
});
|
||||
} else {
|
||||
result.push({
|
||||
uid: `unloaded-${wc.path}`,
|
||||
name: wc.name,
|
||||
pathname: wc.path,
|
||||
items: [],
|
||||
environments: [],
|
||||
isGitBacked: !!wc.remote,
|
||||
isLoaded: false,
|
||||
gitRemoteUrl: wc.remote,
|
||||
git: { gitRootPath: null },
|
||||
brunoConfig: {},
|
||||
root: {
|
||||
request: {
|
||||
headers: [],
|
||||
auth: { mode: 'none' },
|
||||
vars: { req: [], res: [] },
|
||||
script: { req: '', res: '' },
|
||||
tests: ''
|
||||
},
|
||||
docs: ''
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [workspace.collections, collections, workspace.pathname]);
|
||||
|
||||
const handleOpenCollectionClick = (collection, event) => {
|
||||
if (event.target.closest('.action-buttons')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (collection.isLoaded === false) {
|
||||
if (collection.isGitBacked) {
|
||||
toast.error(`Collection "${collection.name}" needs to be cloned first`);
|
||||
} else {
|
||||
toast.error(`Collection "${collection.name}" does not exist on disk`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(mountCollection({
|
||||
collectionUid: collection.uid,
|
||||
collectionPathname: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
}));
|
||||
|
||||
dispatch(hideHomePage());
|
||||
|
||||
dispatch(addTab({
|
||||
uid: collection.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-settings'
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRenameCollection = (collection) => {
|
||||
if (collection.isLoaded === false) {
|
||||
toast.error('Cannot rename collections that are not cloned yet');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCollectionUid(collection.uid);
|
||||
setRenameCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleShareCollection = (collection) => {
|
||||
if (collection.isLoaded === false) {
|
||||
toast.error('Please clone this collection first before sharing it');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(mountCollection({
|
||||
collectionUid: collection.uid,
|
||||
collectionPathname: collection.pathname,
|
||||
brunoConfig: collection.brunoConfig
|
||||
}));
|
||||
|
||||
setSelectedCollectionUid(collection.uid);
|
||||
setShareCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleRemoveCollection = (collection) => {
|
||||
setCollectionToRemove(collection);
|
||||
};
|
||||
|
||||
const confirmRemoveCollection = async () => {
|
||||
if (!collectionToRemove) return;
|
||||
|
||||
try {
|
||||
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
|
||||
const isDelete = collectionInfo.isInternal && !collectionInfo.isGitBacked;
|
||||
|
||||
await dispatch(removeCollectionFromWorkspaceAction(workspace.uid, collectionToRemove.pathname));
|
||||
|
||||
if (isDelete) {
|
||||
toast.success(`Deleted "${collectionToRemove.name}" collection`);
|
||||
} else {
|
||||
toast.success(`Removed "${collectionToRemove.name}" from workspace`);
|
||||
}
|
||||
|
||||
setCollectionToRemove(null);
|
||||
} catch (error) {
|
||||
console.error('Error removing collection:', error);
|
||||
toast.error(error.message || 'Failed to remove collection from workspace');
|
||||
}
|
||||
};
|
||||
|
||||
const isInternalCollection = (collection) => {
|
||||
if (!workspace.pathname || !collection.pathname) return false;
|
||||
const workspaceCollectionsFolder = normalizePath(`${workspace.pathname}/collections`);
|
||||
const collectionPath = normalizePath(collection.pathname);
|
||||
return collectionPath.startsWith(workspaceCollectionsFolder);
|
||||
};
|
||||
|
||||
const getCollectionWorkspaceInfo = (collection) => {
|
||||
if (collection.hasOwnProperty('isGitBacked')) {
|
||||
return {
|
||||
isGitBacked: collection.isGitBacked,
|
||||
gitRemoteUrl: collection.gitRemoteUrl,
|
||||
isLoaded: collection.isLoaded !== false,
|
||||
isInternal: isInternalCollection(collection)
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceCollection = workspace.collections?.find((wc) => {
|
||||
return normalizePath(collection.pathname) === normalizePath(wc.path);
|
||||
});
|
||||
|
||||
return {
|
||||
isGitBacked: !!workspaceCollection?.remote,
|
||||
gitRemoteUrl: workspaceCollection?.remote,
|
||||
isLoaded: true,
|
||||
isInternal: isInternalCollection(collection)
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full">
|
||||
<div className="w-full h-full">
|
||||
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
handleSubmit={handleImportCollection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renameCollectionModalOpen && selectedCollectionUid && (
|
||||
<RenameCollection
|
||||
collectionUid={selectedCollectionUid}
|
||||
onClose={() => {
|
||||
setRenameCollectionModalOpen(false);
|
||||
setSelectedCollectionUid(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareCollectionModalOpen && selectedCollectionUid && (
|
||||
<ShareCollection
|
||||
collectionUid={selectedCollectionUid}
|
||||
onClose={() => {
|
||||
setShareCollectionModalOpen(false);
|
||||
setSelectedCollectionUid(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{collectionToRemove && (() => {
|
||||
const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove);
|
||||
const isDelete = collectionInfo.isInternal && !collectionInfo.isGitBacked;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="sm"
|
||||
title={isDelete ? 'Delete Collection' : 'Remove Collection'}
|
||||
handleCancel={() => setCollectionToRemove(null)}
|
||||
handleConfirm={confirmRemoveCollection}
|
||||
confirmText={isDelete ? 'Delete' : 'Remove'}
|
||||
cancelText="Cancel"
|
||||
style="new"
|
||||
>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Are you sure you want to {isDelete ? 'delete' : 'remove'} <strong>"{collectionToRemove.name}"</strong>?
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-3">
|
||||
{isDelete
|
||||
? 'This will permanently delete the collection files from the workspace collections folder.'
|
||||
: 'This will remove the collection from the workspace. The collection files will not be deleted.'}
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="h-full overflow-auto">
|
||||
{workspaceCollections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-full mb-4">
|
||||
<IconBox size={32} stroke={1.5} className="text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium mb-2">No collections yet</h3>
|
||||
<p className="text-muted mb-4">
|
||||
Create your first collection or open an existing one to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="collections-table">
|
||||
<div className="collections-header">
|
||||
<div className="header-cell header-name">Collection</div>
|
||||
<div className="header-cell header-location">Location</div>
|
||||
<div className="header-cell flex justify-end">Actions</div>
|
||||
</div>
|
||||
|
||||
<div className="collections-body">
|
||||
{workspaceCollections.map((collection, index) => {
|
||||
return (
|
||||
<div
|
||||
key={collection.uid || index}
|
||||
className="collection-row"
|
||||
onClick={(e) => handleOpenCollectionClick(collection, e)}
|
||||
>
|
||||
<div className="row-cell cell-name">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconBox size={16} stroke={1.5} className="collection-icon" />
|
||||
<div className="collection-info">
|
||||
<div className="collection-name">{collection.name}</div>
|
||||
{collection.brunoConfig?.name && collection.brunoConfig.name !== collection.name && (
|
||||
<div className="collection-subtitle">{collection.brunoConfig.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row-cell cell-location">
|
||||
<div className="location-text" title={collection.pathname}>
|
||||
{collection.pathname}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row-cell cell-actions">
|
||||
<div className="action-buttons">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRenameCollection(collection);
|
||||
}}
|
||||
className="action-btn action-edit"
|
||||
title="Rename collection"
|
||||
>
|
||||
<IconEdit size={16} stroke={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleShareCollection(collection);
|
||||
}}
|
||||
className="action-btn action-share"
|
||||
title="Share collection"
|
||||
>
|
||||
<IconShare size={16} stroke={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCollection(collection);
|
||||
}}
|
||||
className="action-btn action-delete"
|
||||
title="Remove from workspace"
|
||||
>
|
||||
<IconTrash size={16} stroke={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceCollections;
|
||||
@@ -0,0 +1,9 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.editing-mode {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'github-markdown-css/github-markdown.css';
|
||||
import get from 'lodash/get';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { saveWorkspaceDocs } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const WorkspaceDocs = ({ workspace }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { displayedTheme } = useTheme();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [localDocs, setLocalDocs] = useState(workspace?.docs || '');
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalDocs(workspace?.docs || '');
|
||||
setIsEditing(false);
|
||||
}, [workspace?.uid, workspace?.docs]);
|
||||
|
||||
const toggleViewMode = () => {
|
||||
setIsEditing((prev) => !prev);
|
||||
};
|
||||
|
||||
const onEdit = (value) => {
|
||||
setLocalDocs(value);
|
||||
};
|
||||
|
||||
const handleDiscardChanges = () => {
|
||||
setLocalDocs(workspace?.docs || '');
|
||||
toggleViewMode();
|
||||
};
|
||||
|
||||
const onSave = async () => {
|
||||
if (!workspace) {
|
||||
toast.error('Workspace not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(saveWorkspaceDocs(workspace.uid, localDocs));
|
||||
toast.success('Documentation saved successfully');
|
||||
toggleViewMode();
|
||||
} catch (error) {
|
||||
console.error('Error saving workspace docs:', error);
|
||||
toast.error('Failed to save documentation');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full w-full relative flex flex-col p-4">
|
||||
<div className="flex flex-row w-full justify-between items-center mb-4">
|
||||
<div className="text-lg font-medium flex items-center gap-2">
|
||||
<IconFileText size={20} strokeWidth={1.5} />
|
||||
Workspace Documentation
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 items-center justify-center">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div className="editing-mode" role="tab" onClick={handleDiscardChanges}>
|
||||
<IconX className="cursor-pointer" size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
<button type="submit" className="submit btn btn-sm btn-secondary" onClick={onSave}>
|
||||
Save
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={localDocs}
|
||||
onEdit={onEdit}
|
||||
onSave={onSave}
|
||||
mode="markdown"
|
||||
font={get(preferences, 'font.codeFont', 'default')}
|
||||
fontSize={get(preferences, 'font.codeFontSize')}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-auto pl-1">
|
||||
<div className="h-[1px] min-h-[500px]">
|
||||
{
|
||||
localDocs?.length > 0
|
||||
? <Markdown onDoubleClick={toggleViewMode} content={localDocs} />
|
||||
: <Markdown onDoubleClick={toggleViewMode} content={workspaceDocumentationPlaceholder} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceDocs;
|
||||
|
||||
const workspaceDocumentationPlaceholder = `
|
||||
# Welcome to your Workspace Documentation
|
||||
|
||||
This is your workspace documentation area where you can document your entire project, team guidelines, and shared resources.
|
||||
|
||||
## What to Document Here
|
||||
|
||||
### Project Overview
|
||||
- Project goals and objectives
|
||||
- Architecture overview
|
||||
- Key stakeholders and team members
|
||||
- Project timeline and milestones
|
||||
|
||||
### Development Guidelines
|
||||
- Coding standards and conventions
|
||||
- Git workflow and branching strategy
|
||||
- Code review process
|
||||
- Testing guidelines
|
||||
|
||||
### API Documentation
|
||||
- Authentication methods
|
||||
- Base URLs and environments
|
||||
- Common headers and parameters
|
||||
- Error handling standards
|
||||
|
||||
### Team Resources
|
||||
- Useful links and references
|
||||
- Development environment setup
|
||||
- Deployment procedures
|
||||
- Troubleshooting guides
|
||||
|
||||
## Markdown Support
|
||||
|
||||
This documentation supports full Markdown formatting:
|
||||
|
||||
- **Bold** and *italic* text
|
||||
- \`inline code\` and code blocks
|
||||
- Lists and tables
|
||||
- [Links](https://usebruno.com) and images
|
||||
- Headers and sections
|
||||
|
||||
**Tip:** Double-click anywhere in this area to start editing!
|
||||
`;
|
||||
@@ -0,0 +1,78 @@
|
||||
import Modal from 'components/Modal/index';
|
||||
import Portal from 'components/Portal/index';
|
||||
import { useFormik } from 'formik';
|
||||
import { copyGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const CopyEnvironment = ({ environment, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
name: environment.name + ' - Copy'
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string()
|
||||
.min(1, 'must be at least 1 character')
|
||||
.max(50, 'must be 50 characters or less')
|
||||
.required('name is required')
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(copyGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
|
||||
.then(() => {
|
||||
toast.success('Environment created!');
|
||||
onClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while creating the environment');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = () => {
|
||||
formik.handleSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Copy Environment" confirmText="Copy" handleConfirm={onSubmit} handleCancel={onClose}>
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<label htmlFor="environment-name" className="block font-semibold">
|
||||
New Environment Name
|
||||
</label>
|
||||
<input
|
||||
id="environment-name"
|
||||
type="text"
|
||||
name="name"
|
||||
ref={inputRef}
|
||||
className="block textbox mt-2 w-full"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name || ''}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name ? (
|
||||
<div className="text-red-500">{formik.errors.name}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CopyEnvironment;
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
|
||||
const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
const trimmedName = name?.toLowerCase().trim();
|
||||
return (globalEnvs || []).every((env) => env?.name?.toLowerCase().trim() !== trimmedName);
|
||||
};
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const inputRef = useRef();
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
name: ''
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string()
|
||||
.min(1, 'Must be at least 1 character')
|
||||
.max(255, 'Must be 255 characters or less')
|
||||
.test('is-valid-filename', function (value) {
|
||||
const isValid = validateName(value);
|
||||
return isValid ? true : this.createError({ message: validateNameError(value) });
|
||||
})
|
||||
.required('Name is required')
|
||||
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
dispatch(addGlobalEnvironment({ name: values.name }))
|
||||
.then(() => {
|
||||
toast.success('Environment created!');
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while creating the environment'));
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = () => {
|
||||
formik.handleSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Create Environment"
|
||||
confirmText="Create"
|
||||
handleConfirm={onSubmit}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block font-semibold">
|
||||
Environment Name
|
||||
</label>
|
||||
<div className="flex items-center mt-2">
|
||||
<input
|
||||
id="environment-name"
|
||||
type="text"
|
||||
name="name"
|
||||
ref={inputRef}
|
||||
className="block textbox w-full"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name || ''}
|
||||
/>
|
||||
</div>
|
||||
{formik.touched.name && formik.errors.name ? (
|
||||
<div className="text-red-500">{formik.errors.name}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateEnvironment;
|
||||
@@ -0,0 +1,15 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
button.submit {
|
||||
color: white;
|
||||
background-color: var(--color-background-danger) !important;
|
||||
border: inherit !important;
|
||||
|
||||
&:hover {
|
||||
border: inherit !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal/index';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal/index';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { deleteGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
|
||||
const DeleteEnvironment = ({ onClose, environment }) => {
|
||||
const dispatch = useDispatch();
|
||||
const onConfirm = () => {
|
||||
dispatch(deleteGlobalEnvironment({ environmentUid: environment.uid }))
|
||||
.then(() => {
|
||||
toast.success('Environment deleted successfully');
|
||||
onClose();
|
||||
})
|
||||
.catch(() => toast.error('An error occurred while deleting the environment'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Delete Environment"
|
||||
confirmText="Delete"
|
||||
handleConfirm={onConfirm}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
Are you sure you want to delete <span className="font-semibold">{environment.name}</span> ?
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteEnvironment;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { IconAlertTriangle } from '@tabler/icons';
|
||||
import Modal from 'components/Modal';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const ConfirmSwitchEnv = ({ onCancel }) => {
|
||||
const modalContent = (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Unsaved changes"
|
||||
disableEscapeKey={true}
|
||||
disableCloseOnOutsideClick={true}
|
||||
closeModalFadeTimeout={150}
|
||||
handleCancel={onCancel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
hideFooter={true}
|
||||
>
|
||||
<div className="flex items-center font-normal">
|
||||
<IconAlertTriangle size={32} strokeWidth={1.5} className="text-yellow-600" />
|
||||
<h1 className="ml-2 text-lg font-semibold">Hold on..</h1>
|
||||
</div>
|
||||
<div className="font-normal mt-4">You have unsaved changes in this environment.</div>
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<div>
|
||||
<button className="btn btn-sm btn-danger" onClick={onCancel}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default ConfirmSwitchEnv;
|
||||
@@ -0,0 +1,195 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const Wrapper = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.table-container {
|
||||
overflow-y: auto;
|
||||
border-radius: 8px;
|
||||
border: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
font-size: 12px;
|
||||
|
||||
thead,
|
||||
td {
|
||||
padding: 4px 12px;
|
||||
|
||||
&:nth-child(1),
|
||||
&:nth-child(4) {
|
||||
width: 80px;
|
||||
}
|
||||
&:nth-child(5) {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
width: 30%;
|
||||
}
|
||||
}
|
||||
|
||||
thead {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
user-select: none;
|
||||
|
||||
td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
font-weight: 600;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
}
|
||||
|
||||
&:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
td {
|
||||
border-bottom: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
border-right: ${(props) => props.theme.workspace.environments.indentBorder};
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-add-param {
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.textLink};
|
||||
font-weight: 500;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 6px;
|
||||
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
background: transparent;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
border-color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-mod {
|
||||
font-size: 11px !important;
|
||||
max-width: 200px !important;
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
outline: none !important;
|
||||
background-color: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
cursor: pointer;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: ${(props) => props.theme.workspace.accent};
|
||||
}
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
padding: 12px 0;
|
||||
background: ${(props) => props.theme.bg};
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.submit {
|
||||
padding: 7px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
background: ${(props) => props.theme.workspace.accent};
|
||||
color: ${(props) => props.theme.bg};
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.reset {
|
||||
background: transparent;
|
||||
padding: 6px 16px;
|
||||
border: 1px solid ${(props) => props.theme.workspace.accent};
|
||||
color: ${(props) => props.theme.workspace.accent};
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.discard {
|
||||
padding: 7px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text};
|
||||
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default Wrapper;
|
||||
@@ -0,0 +1,344 @@
|
||||
import React from 'react';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import MultiLineEditor from 'components/MultiLineEditor/index';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { uuid } from 'utils/common';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { variableNameRegex } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { Tooltip } from 'react-tooltip';
|
||||
import { getGlobalEnvironmentVariables } from 'utils/collections';
|
||||
|
||||
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
let _collection = collection ? cloneDeep(collection) : {};
|
||||
|
||||
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
|
||||
if (_collection) {
|
||||
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
|
||||
}
|
||||
|
||||
const initialValues = React.useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
return [
|
||||
...vars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
}, [environment.uid, environment.variables]);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: initialValues,
|
||||
validationSchema: Yup.array().of(Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
name: Yup.string()
|
||||
.when('$isLastRow', {
|
||||
is: true,
|
||||
then: (schema) => schema.optional(),
|
||||
otherwise: (schema) => schema
|
||||
.required('Name cannot be empty')
|
||||
.matches(variableNameRegex,
|
||||
'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.')
|
||||
.trim()
|
||||
}),
|
||||
secret: Yup.boolean(),
|
||||
type: Yup.string(),
|
||||
uid: Yup.string(),
|
||||
value: Yup.mixed().nullable()
|
||||
})),
|
||||
validate: (values) => {
|
||||
const errors = {};
|
||||
values.forEach((variable, index) => {
|
||||
const isLastRow = index === values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
|
||||
// Skip validation for the last empty row
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate name for non-empty rows
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name = 'Name cannot be empty';
|
||||
} else if (!variableNameRegex.test(variable.name)) {
|
||||
if (!errors[index]) errors[index] = {};
|
||||
errors[index].name = 'Name contains invalid characters. Must only contain alphanumeric characters, "-", "_", "." and cannot start with a digit.';
|
||||
}
|
||||
});
|
||||
return Object.keys(errors).length > 0 ? errors : {};
|
||||
},
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
const hasActualChanges = JSON.stringify(currentValues) !== JSON.stringify(savedValues);
|
||||
|
||||
setIsModified(hasActualChanges);
|
||||
}, [formik.values, environment.variables, setIsModified]);
|
||||
|
||||
const ErrorMessage = ({ name, index }) => {
|
||||
const meta = formik.getFieldMeta(name);
|
||||
const id = `error-${name}-${index}`;
|
||||
|
||||
// Don't show error for the last empty row
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const variable = formik.values[index];
|
||||
const isEmptyRow = !variable?.name || variable.name.trim() === '';
|
||||
|
||||
if (isLastRow && isEmptyRow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!meta.error || !meta.touched) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
<IconAlertCircle id={id} className="text-red-600 cursor-pointer" size={20} />
|
||||
<Tooltip className="tooltip-mod" anchorId={id} html={meta.error || ''} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveVar = (id) => {
|
||||
const filteredValues = formik.values.filter((variable) => variable.uid !== id);
|
||||
|
||||
const lastRow = formik.values[formik.values.length - 1];
|
||||
const isLastEmptyRow = lastRow.uid === id && (!lastRow.name || lastRow.name.trim() === '');
|
||||
|
||||
if (isLastEmptyRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasEmptyLastRow = filteredValues.length > 0
|
||||
&& (!filteredValues[filteredValues.length - 1].name
|
||||
|| filteredValues[filteredValues.length - 1].name.trim() === '');
|
||||
|
||||
if (!hasEmptyLastRow) {
|
||||
filteredValues.push({
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
});
|
||||
}
|
||||
|
||||
formik.setValues(filteredValues);
|
||||
};
|
||||
|
||||
const handleNameChange = (index, e) => {
|
||||
formik.handleChange(e);
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
|
||||
// If typing in the last row, add a new empty row
|
||||
if (isLastRow) {
|
||||
const newVariable = {
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
};
|
||||
// Use setTimeout to ensure the change is processed first
|
||||
setTimeout(() => {
|
||||
formik.setFieldValue(formik.values.length, newVariable, false);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
|
||||
const hasValidationErrors = variablesToSave.some((variable) => {
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
return true;
|
||||
}
|
||||
if (!variableNameRegex.test(variable.name)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (hasValidationErrors) {
|
||||
toast.error('Please fix validation errors before saving');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(saveGlobalEnvironment({ environmentUid: environment.uid, variables: cloneDeep(variablesToSave) }))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: newValues });
|
||||
setIsModified(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const originalVars = environment.variables || [];
|
||||
const resetValues = [
|
||||
...originalVars,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: resetValues });
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<div className="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td className="text-center">Enabled</td>
|
||||
<td>Name</td>
|
||||
<td>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formik.values.map((variable, index) => {
|
||||
const isLastRow = index === formik.values.length - 1;
|
||||
const isEmptyRow = !variable.name || variable.name.trim() === '';
|
||||
const isLastEmptyRow = isLastRow && isEmptyRow;
|
||||
|
||||
return (
|
||||
<tr key={variable.uid}>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${index}.enabled`}
|
||||
checked={variable.enabled}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
className="mousetrap"
|
||||
id={`${index}.name`}
|
||||
name={`${index}.name`}
|
||||
value={variable.name}
|
||||
placeholder={isLastEmptyRow ? 'Name' : ''}
|
||||
onChange={(e) => handleNameChange(index, e)}
|
||||
/>
|
||||
<ErrorMessage name={`${index}.name`} index={index} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="flex flex-row flex-nowrap items-center">
|
||||
<div className="overflow-hidden grow w-full relative">
|
||||
<MultiLineEditor
|
||||
theme={storedTheme}
|
||||
collection={_collection}
|
||||
name={`${index}.value`}
|
||||
value={variable.value}
|
||||
placeholder={isLastEmptyRow ? 'Value' : ''}
|
||||
isSecret={variable.secret}
|
||||
readOnly={typeof variable.value !== 'string'}
|
||||
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
|
||||
/>
|
||||
</div>
|
||||
{typeof variable.value !== 'string' && (
|
||||
<span className="ml-2 flex items-center">
|
||||
<IconInfoCircle
|
||||
id={`${variable.uid}-disabled-info-icon`}
|
||||
className="text-muted"
|
||||
size={16}
|
||||
/>
|
||||
<Tooltip
|
||||
anchorId={`${variable.uid}-disabled-info-icon`}
|
||||
content="Non-string values set via scripts are read-only and can only be updated through scripts."
|
||||
place="top"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-center">
|
||||
{!isLastEmptyRow && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mousetrap"
|
||||
name={`${index}.secret`}
|
||||
checked={variable.secret}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{!isLastEmptyRow && (
|
||||
<button onClick={() => handleRemoveVar(variable.uid)}>
|
||||
<IconTrash strokeWidth={1.5} size={18} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="button-container">
|
||||
<div className="flex items-center">
|
||||
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="submit reset ml-2" onClick={handleReset} data-testid="reset-env">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
export default EnvironmentVariables;
|
||||
@@ -0,0 +1,316 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import styled from 'styled-components';
|
||||
import CopyEnvironment from '../../CopyEnvironment';
|
||||
import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: ${(props) => props.theme.bg};
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px 12px 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
|
||||
&.renaming {
|
||||
.title-input {
|
||||
flex: 1;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
outline: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title-error {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => `${props.theme.colors.text.danger}15`};
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:last-child:hover {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px 20px 20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
const [openDeleteModal, setOpenDeleteModal] = useState(false);
|
||||
const [openCopyModal, setOpenCopyModal] = useState(false);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const validateEnvironmentName = (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';
|
||||
}
|
||||
|
||||
if (!validateName(name)) {
|
||||
return validateNameError(name);
|
||||
}
|
||||
|
||||
const trimmedName = name.toLowerCase().trim();
|
||||
const isDuplicate = (globalEnvs || []).some((env) =>
|
||||
env?.uid !== environment.uid && env?.name?.toLowerCase().trim() === trimmedName);
|
||||
if (isDuplicate) {
|
||||
return 'Environment already exists';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleRenameClick = () => {
|
||||
setIsRenaming(true);
|
||||
setNewName(environment.name);
|
||||
setNameError('');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleSaveRename = () => {
|
||||
const error = validateEnvironmentName(newName);
|
||||
if (error) {
|
||||
setNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(renameGlobalEnvironment({ name: newName, environmentUid: environment.uid }))
|
||||
.then(() => {
|
||||
toast.success('Environment renamed!');
|
||||
setIsRenaming(false);
|
||||
setNewName('');
|
||||
setNameError('');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while renaming the environment');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setIsRenaming(false);
|
||||
setNewName('');
|
||||
setNameError('');
|
||||
};
|
||||
|
||||
const handleNameChange = (e) => {
|
||||
setNewName(e.target.value);
|
||||
if (nameError) {
|
||||
setNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
if (newName.trim() === '') {
|
||||
handleCancelRename();
|
||||
} else {
|
||||
const error = validateEnvironmentName(newName);
|
||||
if (error) {
|
||||
setNameError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openDeleteModal && (
|
||||
<DeleteEnvironment
|
||||
onClose={() => setOpenDeleteModal(false)}
|
||||
environment={environment}
|
||||
/>
|
||||
)}
|
||||
{openCopyModal && (
|
||||
<CopyEnvironment onClose={() => setOpenCopyModal(false)} environment={environment} />
|
||||
)}
|
||||
|
||||
<div className="header">
|
||||
<div className={`title-container ${isRenaming ? 'renaming' : ''}`}>
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="title-input"
|
||||
value={newName}
|
||||
onChange={handleNameChange}
|
||||
onBlur={handleNameBlur}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<h2 className="title">{environment.name}</h2>
|
||||
)}
|
||||
</div>
|
||||
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
|
||||
<div className="actions">
|
||||
<button onClick={handleRenameClick} title="Rename">
|
||||
<IconEdit size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button onClick={() => setOpenCopyModal(true)} title="Copy">
|
||||
<IconCopy size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button onClick={() => setOpenDeleteModal(true)} title="Delete">
|
||||
<IconTrash size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentDetails;
|
||||
@@ -0,0 +1,280 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
position: relative;
|
||||
|
||||
.environments-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm-switch-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: ${(props) => props.theme.bg};
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
}
|
||||
|
||||
/* Left Sidebar */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px 16px;
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
position: relative;
|
||||
padding: 0 12px 12px 12px;
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-100%);
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 6px 8px 6px 28px;
|
||||
font-size: 12px;
|
||||
background: transparent;
|
||||
border: ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
border-radius: 5px;
|
||||
color: ${(props) => props.theme.text};
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.environments-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.environment-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
margin-bottom: 1px;
|
||||
font-size: 13px;
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
.environment-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.environment-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
.activate-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.text.muted};
|
||||
border-radius: 3px;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.workspace.button.bg};
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
}
|
||||
}
|
||||
|
||||
.activated-checkmark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .environment-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.activated .environment-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.workspace.button.bg};
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: ${(props) => props.theme.workspace.environments.activeBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.renaming,
|
||||
&.creating {
|
||||
cursor: default;
|
||||
padding: 4px 4px 4px 8px;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.workspace.button.bg};
|
||||
}
|
||||
}
|
||||
|
||||
.rename-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
|
||||
.environment-name-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.creating {
|
||||
.environment-name-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
}
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.textLink};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.env-error {
|
||||
padding: 4px 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
background: ${(props) => `${props.theme.colors.text.danger}15`};
|
||||
border-radius: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,426 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import usePrevious from 'hooks/usePrevious';
|
||||
import EnvironmentDetails from './EnvironmentDetails';
|
||||
import CreateEnvironment from '../CreateEnvironment';
|
||||
import { IconDownload, IconSearch, IconPlus, IconCheck, IconX } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ConfirmSwitchEnv from './ConfirmSwitchEnv';
|
||||
import ImportEnvironment from '../ImportEnvironment';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
|
||||
const [openCreateModal, setOpenCreateModal] = useState(false);
|
||||
const [openImportModal, setOpenImportModal] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isCreatingInline, setIsCreatingInline] = useState(false);
|
||||
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
|
||||
const [newEnvName, setNewEnvName] = useState('');
|
||||
const [envNameError, setEnvNameError] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
const renameContainerRef = useRef(null);
|
||||
const createContainerRef = useRef(null);
|
||||
|
||||
const [switchEnvConfirmClose, setSwitchEnvConfirmClose] = useState(false);
|
||||
const [originalEnvironmentVariables, setOriginalEnvironmentVariables] = useState([]);
|
||||
|
||||
const envUids = environments ? environments.map((env) => env.uid) : [];
|
||||
const prevEnvUids = usePrevious(envUids);
|
||||
|
||||
useEffect(() => {
|
||||
if (!environments?.length) {
|
||||
setSelectedEnvironment(null);
|
||||
setOriginalEnvironmentVariables([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEnvironment) {
|
||||
let _selectedEnvironment = environments?.find((env) => env?.uid === selectedEnvironment?.uid);
|
||||
|
||||
if (!_selectedEnvironment) {
|
||||
_selectedEnvironment = environments?.find((env) => env?.name === selectedEnvironment?.name);
|
||||
}
|
||||
|
||||
if (!_selectedEnvironment) {
|
||||
_selectedEnvironment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
|
||||
}
|
||||
|
||||
const hasSelectedEnvironmentChanged = !isEqual(selectedEnvironment, _selectedEnvironment);
|
||||
if (hasSelectedEnvironmentChanged || selectedEnvironment.uid !== _selectedEnvironment?.uid) {
|
||||
setSelectedEnvironment(_selectedEnvironment);
|
||||
}
|
||||
setOriginalEnvironmentVariables(_selectedEnvironment?.variables || []);
|
||||
return;
|
||||
}
|
||||
|
||||
const environment = environments?.find((env) => env.uid === activeEnvironmentUid) || environments?.[0];
|
||||
|
||||
setSelectedEnvironment(environment);
|
||||
setOriginalEnvironmentVariables(environment?.variables || []);
|
||||
}, [environments, activeEnvironmentUid, selectedEnvironment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevEnvUids && prevEnvUids.length && envUids.length > prevEnvUids.length) {
|
||||
const newEnv = environments.find((env) => !prevEnvUids.includes(env.uid));
|
||||
if (newEnv) {
|
||||
setSelectedEnvironment(newEnv);
|
||||
}
|
||||
}
|
||||
|
||||
if (prevEnvUids && prevEnvUids.length && envUids.length < prevEnvUids.length) {
|
||||
setSelectedEnvironment(environments && environments.length ? environments[0] : null);
|
||||
}
|
||||
}, [envUids, environments, prevEnvUids]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingEnvUid) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (renameContainerRef.current && !renameContainerRef.current.contains(event.target)) {
|
||||
handleCancelRename();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [renamingEnvUid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreatingInline) return;
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (createContainerRef.current && !createContainerRef.current.contains(event.target)) {
|
||||
handleCancelCreate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isCreatingInline]);
|
||||
|
||||
const handleEnvironmentClick = (env) => {
|
||||
if (!isModified) {
|
||||
setSelectedEnvironment(env);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnvironmentDoubleClick = (env) => {
|
||||
setRenamingEnvUid(env.uid);
|
||||
setNewEnvName(env.name);
|
||||
setEnvNameError('');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleActivateEnvironment = (e, env) => {
|
||||
e.stopPropagation();
|
||||
dispatch(selectGlobalEnvironment({ environmentUid: env.uid }))
|
||||
.then(() => {
|
||||
toast.success(`Environment "${env.name}" activated`);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to activate environment');
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validateEnvironmentName = (name, excludeUid = null) => {
|
||||
if (!name || name.trim() === '') {
|
||||
return 'Name is required';
|
||||
}
|
||||
|
||||
if (!validateName(name)) {
|
||||
return validateNameError(name);
|
||||
}
|
||||
|
||||
const trimmedName = name.toLowerCase().trim();
|
||||
const isDuplicate = globalEnvs.some((env) =>
|
||||
env?.uid !== excludeUid && env?.name?.toLowerCase().trim() === trimmedName);
|
||||
if (isDuplicate) {
|
||||
return 'Environment already exists';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleCreateEnvClick = () => {
|
||||
if (!isModified) {
|
||||
setIsCreatingInline(true);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelCreate = () => {
|
||||
setIsCreatingInline(false);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
};
|
||||
|
||||
const handleSaveNewEnv = () => {
|
||||
const error = validateEnvironmentName(newEnvName);
|
||||
if (error) {
|
||||
setEnvNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(addGlobalEnvironment({ name: newEnvName }))
|
||||
.then(() => {
|
||||
toast.success('Environment created!');
|
||||
setIsCreatingInline(false);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while creating the environment');
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnvNameChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setNewEnvName(value);
|
||||
|
||||
if (envNameError) {
|
||||
setEnvNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnvNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (renamingEnvUid) {
|
||||
handleSaveRename();
|
||||
} else {
|
||||
handleSaveNewEnv();
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (renamingEnvUid) {
|
||||
handleCancelRename();
|
||||
} else {
|
||||
handleCancelCreate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRename = () => {
|
||||
const error = validateEnvironmentName(newEnvName, renamingEnvUid);
|
||||
if (error) {
|
||||
setEnvNameError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(renameGlobalEnvironment({ name: newEnvName, environmentUid: renamingEnvUid }))
|
||||
.then(() => {
|
||||
toast.success('Environment renamed!');
|
||||
setRenamingEnvUid(null);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('An error occurred while renaming the environment');
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelRename = () => {
|
||||
setRenamingEnvUid(null);
|
||||
setNewEnvName('');
|
||||
setEnvNameError('');
|
||||
};
|
||||
|
||||
const handleImportClick = () => {
|
||||
if (!isModified) {
|
||||
setOpenImportModal(true);
|
||||
} else {
|
||||
setSwitchEnvConfirmClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmSwitch = (saveChanges) => {
|
||||
if (!saveChanges) {
|
||||
setSwitchEnvConfirmClose(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEnvironments = environments?.filter((env) =>
|
||||
env.name.toLowerCase().includes(searchText.toLowerCase())) || [];
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openCreateModal && <CreateEnvironment onClose={() => setOpenCreateModal(false)} />}
|
||||
{openImportModal && <ImportEnvironment onClose={() => setOpenImportModal(false)} />}
|
||||
|
||||
<div className="environments-container">
|
||||
{switchEnvConfirmClose && (
|
||||
<div className="confirm-switch-overlay">
|
||||
<ConfirmSwitchEnv onCancel={() => handleConfirmSwitch(false)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left Sidebar */}
|
||||
<div className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<h2 className="title">Environments</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
|
||||
<IconPlus size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button className="btn-action" onClick={() => handleImportClick()} title="Import environment">
|
||||
<IconDownload size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="search-container">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search environments..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="environments-list">
|
||||
{filteredEnvironments.map((env) => (
|
||||
<div
|
||||
key={env.uid}
|
||||
id={env.uid}
|
||||
className={`environment-item ${selectedEnvironment.uid === env.uid ? 'active' : ''} ${renamingEnvUid === env.uid ? 'renaming' : ''} ${activeEnvironmentUid === env.uid ? 'activated' : ''}`}
|
||||
onClick={() => renamingEnvUid !== env.uid && handleEnvironmentClick(env)}
|
||||
onDoubleClick={() => handleEnvironmentDoubleClick(env)}
|
||||
>
|
||||
{renamingEnvUid === env.uid ? (
|
||||
<div className="rename-container" ref={renameContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelRename}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span className="environment-name">{env.name}</span>
|
||||
<div className="environment-actions">
|
||||
{activeEnvironmentUid === env.uid ? (
|
||||
<div className="activated-checkmark" title="Active environment">
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="activate-btn"
|
||||
onClick={(e) => handleActivateEnvironment(e, env)}
|
||||
title="Activate environment"
|
||||
>
|
||||
<IconCheck size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isCreatingInline && (
|
||||
<div className="environment-item creating" ref={createContainerRef}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="environment-name-input"
|
||||
value={newEnvName}
|
||||
onChange={handleEnvNameChange}
|
||||
onKeyDown={handleEnvNameKeyDown}
|
||||
placeholder="Environment name..."
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="inline-action-btn save"
|
||||
onClick={handleSaveNewEnv}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Save"
|
||||
>
|
||||
<IconCheck size={14} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
className="inline-action-btn cancel"
|
||||
onClick={handleCancelCreate}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Cancel"
|
||||
>
|
||||
<IconX size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{envNameError && (isCreatingInline || renamingEnvUid) && (
|
||||
<div className="env-error">{envNameError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Content */}
|
||||
<EnvironmentDetails
|
||||
environment={selectedEnvironment}
|
||||
setIsModified={setIsModified}
|
||||
originalEnvironmentVariables={originalEnvironmentVariables}
|
||||
collection={collection}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentList;
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import Portal from 'components/Portal';
|
||||
import Modal from 'components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import importPostmanEnvironment from 'utils/importers/postman-environment';
|
||||
import { toastError } from 'utils/common/error';
|
||||
import { IconDatabaseImport } from '@tabler/icons';
|
||||
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
|
||||
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleImportPostmanEnvironment = () => {
|
||||
importPostmanEnvironment()
|
||||
.then((environments) => {
|
||||
const importPromises = environments
|
||||
.filter((env) =>
|
||||
env.name && env.name !== 'undefined')
|
||||
.map((environment) =>
|
||||
dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
|
||||
.then(() => {
|
||||
toast.success('Environment imported successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while importing the environment');
|
||||
console.error(error);
|
||||
}));
|
||||
return Promise.all(importPromises);
|
||||
})
|
||||
.then(() => {
|
||||
onClose();
|
||||
// Call the callback if provided
|
||||
if (onEnvironmentCreated) {
|
||||
onEnvironmentCreated();
|
||||
}
|
||||
})
|
||||
.catch((err) => toastError(err, 'Postman Import environment failed'));
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-environment-modal">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImportPostmanEnvironment}
|
||||
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
|
||||
data-testid="import-postman-environment"
|
||||
>
|
||||
<IconDatabaseImport size={64} />
|
||||
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>
|
||||
</button>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportEnvironment;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Portal from 'components/Portal/index';
|
||||
import Modal from 'components/Modal/index';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
const RenameEnvironment = ({ onClose, environment }) => {
|
||||
const dispatch = useDispatch();
|
||||
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
const inputRef = useRef();
|
||||
|
||||
const validateEnvironmentName = (name) => {
|
||||
const trimmedName = name?.toLowerCase().trim();
|
||||
return (globalEnvs || []).every((env) =>
|
||||
env.uid === environment.uid || env?.name?.toLowerCase().trim() !== trimmedName);
|
||||
};
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
name: environment.name
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
name: Yup.string()
|
||||
.min(1, 'must be at least 1 character')
|
||||
.max(255, 'Must be 255 characters or less')
|
||||
.test('is-valid-filename', function (value) {
|
||||
const isValid = validateName(value);
|
||||
return isValid ? true : this.createError({ message: validateNameError(value) });
|
||||
})
|
||||
.required('name is required')
|
||||
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
if (values.name === environment.name) {
|
||||
return;
|
||||
}
|
||||
dispatch(renameGlobalEnvironment({ name: values.name, environmentUid: environment.uid }))
|
||||
.then(() => {
|
||||
toast.success('Environment renamed successfully');
|
||||
onClose();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error('An error occurred while renaming the environment');
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
const onSubmit = () => {
|
||||
formik.handleSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
size="sm"
|
||||
title="Rename Environment"
|
||||
confirmText="Rename"
|
||||
handleConfirm={onSubmit}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<label htmlFor="name" className="block font-semibold">
|
||||
Environment Name
|
||||
</label>
|
||||
<input
|
||||
id="environment-name"
|
||||
type="text"
|
||||
name="name"
|
||||
ref={inputRef}
|
||||
className="block textbox mt-2 w-full"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name || ''}
|
||||
/>
|
||||
{formik.touched.name && formik.errors.name ? (
|
||||
<div className="text-red-500">{formik.errors.name}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameEnvironment;
|
||||
@@ -0,0 +1,52 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: ${(props) => props.theme.bg};
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
svg {
|
||||
opacity: 0.3;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.shared-button {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
|
||||
background: ${(props) => props.theme.sidebar.bg};
|
||||
color: ${(props) => props.theme.text};
|
||||
cursor: pointer;
|
||||
transition: all 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
border-color: ${(props) => props.theme.textLink};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import CreateEnvironment from './CreateEnvironment';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconFileAlert } from '@tabler/icons';
|
||||
import ImportEnvironment from './ImportEnvironment';
|
||||
|
||||
export const SharedButton = ({ children, className, onClick }) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`rounded bg-transparent px-2.5 py-2 w-fit text-xs font-semibold text-zinc-900 dark:text-zinc-50 shadow-sm ring-1 ring-inset ring-zinc-300 dark:ring-zinc-500 hover:bg-gray-50 dark:hover:bg-zinc-700
|
||||
${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultTab = ({ setTab }) => {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<IconFileAlert size={48} strokeWidth={1.5} />
|
||||
<div className="title">No Environments</div>
|
||||
<div className="actions">
|
||||
<button className="shared-button" onClick={() => setTab('create')}>
|
||||
Create Environment
|
||||
</button>
|
||||
<button className="shared-button" onClick={() => setTab('import')}>
|
||||
Import Environment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkspaceEnvironments = ({ workspace }) => {
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
|
||||
const [tab, setTab] = useState('default');
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
|
||||
if (!globalEnvironments || !globalEnvironments.length) {
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{tab === 'create' ? (
|
||||
<CreateEnvironment onClose={() => setTab('default')} />
|
||||
) : tab === 'import' ? (
|
||||
<ImportEnvironment onClose={() => setTab('default')} />
|
||||
) : (
|
||||
<DefaultTab setTab={setTab} />
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<EnvironmentList
|
||||
environments={globalEnvironments}
|
||||
activeEnvironmentUid={activeGlobalEnvironmentUid}
|
||||
selectedEnvironment={selectedEnvironment}
|
||||
setSelectedEnvironment={setSelectedEnvironment}
|
||||
isModified={isModified}
|
||||
setIsModified={setIsModified}
|
||||
collection={null}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceEnvironments;
|
||||
351
packages/bruno-app/src/components/WorkspaceHome/index.js
Normal file
351
packages/bruno-app/src/components/WorkspaceHome/index.js
Normal file
@@ -0,0 +1,351 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconCategory, IconPlus, IconFolders, IconFileImport, IconDots, IconEdit, IconX, IconCheck, IconFolder } from '@tabler/icons';
|
||||
import { importCollectionInWorkspace, renameWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { showInFolder, openCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import toast from 'react-hot-toast';
|
||||
import CreateCollection from 'components/Sidebar/CreateCollection';
|
||||
import ImportCollection from 'components/Sidebar/ImportCollection';
|
||||
import CloseWorkspace from 'components/Sidebar/SidebarHeader/CloseWorkspace';
|
||||
import WorkspaceCollections from './WorkspaceCollections';
|
||||
import WorkspaceDocs from './WorkspaceDocs';
|
||||
import WorkspaceEnvironments from './WorkspaceEnvironments';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
|
||||
const WorkspaceHome = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
|
||||
const [activeTab, setActiveTab] = useState('collections');
|
||||
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
|
||||
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 handleCreateCollection = async () => {
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
await ipcRenderer.invoke('renderer:ensure-collections-folder', activeWorkspace.pathname);
|
||||
setCreateCollectionModalOpen(true);
|
||||
} catch (error) {
|
||||
console.error('Error ensuring collections folder exists:', error);
|
||||
toast.error('Error preparing workspace for collection creation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCollection = () => {
|
||||
dispatch(openCollection())
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while opening the collection');
|
||||
});
|
||||
};
|
||||
|
||||
const handleImportCollection = () => {
|
||||
setImportCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleImportCollectionSubmit = ({ rawData, type, environment, repositoryUrl }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
dispatch(importCollectionInWorkspace(rawData, activeWorkspace.uid, undefined, type))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
// Workspace menu handlers
|
||||
const handleRenameWorkspaceClick = () => {
|
||||
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) => {
|
||||
console.error('Error opening the folder', error);
|
||||
toast.error('Error opening the folder');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
const value = e.target.value;
|
||||
setWorkspaceNameInput(value);
|
||||
|
||||
if (workspaceNameError) {
|
||||
setWorkspaceNameError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleWorkspaceNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSaveWorkspaceRename();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancelWorkspaceRename();
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'collections',
|
||||
label: 'Collections',
|
||||
component: (
|
||||
<WorkspaceCollections
|
||||
workspace={activeWorkspace}
|
||||
onImportCollection={handleImportCollection}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'environments',
|
||||
label: 'Environments',
|
||||
component: <WorkspaceEnvironments workspace={activeWorkspace} />
|
||||
},
|
||||
{
|
||||
id: 'documentation',
|
||||
label: 'Documentation',
|
||||
component: <WorkspaceDocs workspace={activeWorkspace} />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StyledWrapper className="h-full">
|
||||
<div className="h-full flex flex-col">
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection
|
||||
onClose={() => setCreateCollectionModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-5 p-4 pb-2 workspace-header">
|
||||
<div className="text-xl font-semibold flex items-center gap-2">
|
||||
<IconCategory size={24} stroke={2} />
|
||||
{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>{activeWorkspace.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRenamingWorkspace && activeWorkspace.type !== 'default' && (
|
||||
<Dropdown
|
||||
style="new"
|
||||
placement="bottom-end"
|
||||
onCreate={onDropdownCreate}
|
||||
icon={<IconDots size={20} 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>Show in Folder</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>
|
||||
|
||||
{closeWorkspaceModalOpen && (
|
||||
<CloseWorkspace
|
||||
workspaceUid={activeWorkspace.uid}
|
||||
onClose={() => setCloseWorkspaceModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-4 tabs-container">
|
||||
<div className="flex gap-5">
|
||||
{tabs.map((tab) => {
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 py-2 text-sm border-b-2 transition-colors tab-item ${activeTab === tab.id ? 'active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{activeTab === 'collections' && (
|
||||
<div className="flex items-center gap-1 workspace-action-buttons">
|
||||
<button
|
||||
onClick={handleCreateCollection}
|
||||
className="workspace-button"
|
||||
title="Create Collection"
|
||||
>
|
||||
<IconPlus size={16} stroke={1.5} />
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenCollection}
|
||||
className="workspace-button"
|
||||
title="Add Collection"
|
||||
>
|
||||
<IconFolders size={16} stroke={1.5} />
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportCollection}
|
||||
className="workspace-button"
|
||||
title="Import Collection"
|
||||
>
|
||||
<IconFileImport size={16} stroke={1.5} />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tabs.find((tab) => tab.id === activeTab)?.component}
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceHome;
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import * as Yup from 'yup';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import { createWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { multiLineMsg } from 'utils/common/index';
|
||||
import { formatIpcError } from 'utils/common/error';
|
||||
|
||||
const CreateWorkspace = ({ onClose }) => {
|
||||
const inputRef = useRef();
|
||||
const dispatch = useDispatch();
|
||||
const workspaces = useSelector((state) => state.workspaces.workspaces);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: {
|
||||
workspaceName: '',
|
||||
workspaceLocation: ''
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
workspaceName: Yup.string()
|
||||
.min(1, 'must be at least 1 character')
|
||||
.max(255, 'must be 255 characters or less')
|
||||
.required('workspace name is required')
|
||||
.test('unique-name', 'A workspace with this name already exists', function (value) {
|
||||
if (!value) return true;
|
||||
|
||||
return !workspaces.some((w) =>
|
||||
w.name.toLowerCase() === value.toLowerCase());
|
||||
}),
|
||||
workspaceLocation: Yup.string().min(1, 'location is required').required('location is required')
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
await dispatch(createWorkspaceAction(values.workspaceName, values.workspaceName, values.workspaceLocation));
|
||||
toast.success('Workspace created!');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(multiLineMsg('An error occurred while creating the workspace', formatIpcError(error)));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const browse = () => {
|
||||
dispatch(browseDirectory())
|
||||
.then((dirPath) => {
|
||||
if (typeof dirPath === 'string') {
|
||||
formik.setFieldValue('workspaceLocation', dirPath);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
formik.setFieldValue('workspaceLocation', '');
|
||||
console.error(error);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [inputRef]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="md"
|
||||
title="Create Workspace"
|
||||
description="Give your new workspace a name and choose its type to get started."
|
||||
confirmText={isSubmitting ? 'Creating...' : 'Create Workspace'}
|
||||
handleConfirm={formik.handleSubmit}
|
||||
handleCancel={onClose}
|
||||
style="new"
|
||||
confirmDisabled={isSubmitting}
|
||||
>
|
||||
<div>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label htmlFor="workspaceName" className="block font-semibold mb-2">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="workspace-name"
|
||||
type="text"
|
||||
name="workspaceName"
|
||||
ref={inputRef}
|
||||
className="block textbox w-full"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.workspaceName || ''}
|
||||
/>
|
||||
{formik.touched.workspaceName && formik.errors.workspaceName ? (
|
||||
<div className="text-red-500 text-sm mt-1">{formik.errors.workspaceName}</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label htmlFor="workspaceLocation" className="block font-semibold mb-2">
|
||||
Location
|
||||
<span className="ml-1 text-gray-500 text-sm">
|
||||
<svg className="inline w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="workspace-location"
|
||||
type="text"
|
||||
name="workspaceLocation"
|
||||
readOnly={true}
|
||||
className="block textbox flex-1 bg-gray-50"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={formik.values.workspaceLocation || ''}
|
||||
/>
|
||||
<button type="button" className="btn btn-sm btn-secondary" onClick={browse}>
|
||||
Browse
|
||||
</button>
|
||||
</div>
|
||||
{formik.touched.workspaceLocation && formik.errors.workspaceLocation ? (
|
||||
<div className="text-red-500 text-sm mt-1">{formik.errors.workspaceLocation}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateWorkspace;
|
||||
@@ -2,7 +2,7 @@ import find from 'lodash/find';
|
||||
import { updateRequestPaneTabHeight, updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
const MIN_TOP_PANE_HEIGHT = 150;
|
||||
const MIN_TOP_PANE_HEIGHT = 380;
|
||||
|
||||
export function useTabPaneBoundaries(activeTabUid) {
|
||||
const DEFAULT_PANE_WIDTH_DIVISOR = 2.2;
|
||||
@@ -12,7 +12,7 @@ export function useTabPaneBoundaries(activeTabUid) {
|
||||
const screenWidth = useSelector((state) => state.app.screenWidth);
|
||||
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
|
||||
const left = focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR;
|
||||
const top = focusedTab?.requestPaneHeight;
|
||||
const top = focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import Welcome from 'components/Welcome';
|
||||
import WorkspaceHome from 'components/WorkspaceHome';
|
||||
import RequestTabs from 'components/RequestTabs';
|
||||
import RequestTabPanel from 'components/RequestTabPanel';
|
||||
import Sidebar from 'components/Sidebar';
|
||||
import StatusBar from 'components/StatusBar';
|
||||
import AppTitleBar from 'components/AppTitleBar';
|
||||
// import ErrorCapture from 'components/ErrorCapture';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
@@ -87,6 +88,7 @@ export default function Main() {
|
||||
return (
|
||||
// <ErrorCapture>
|
||||
<div id="main-container" className="flex flex-col h-screen max-h-screen overflow-hidden">
|
||||
<AppTitleBar />
|
||||
{showRosettaBanner ? (
|
||||
<Portal>
|
||||
<div className="fixed bottom-0 left-0 right-0 z-10 bg-amber-100 border border-amber-400 text-amber-700 px-4 py-3" role="alert">
|
||||
@@ -105,14 +107,14 @@ export default function Main() {
|
||||
className="flex-1 min-h-0 flex"
|
||||
data-app-state="loading"
|
||||
style={{
|
||||
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
|
||||
height: isConsoleOpen ? `calc(100vh - 60px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 60px)'
|
||||
}}
|
||||
>
|
||||
<StyledWrapper className={className} style={{ height: '100%', zIndex: 1 }}>
|
||||
<Sidebar />
|
||||
<section className="flex flex-grow flex-col overflow-hidden">
|
||||
{showHomePage ? (
|
||||
<Welcome />
|
||||
<WorkspaceHome />
|
||||
) : (
|
||||
<>
|
||||
<RequestTabs />
|
||||
|
||||
@@ -20,9 +20,24 @@ export const AppProvider = (props) => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const platform = get(navigator, 'platform', '');
|
||||
if (platform && platform.toLowerCase().indexOf('mac') > -1) {
|
||||
const platform = get(navigator, 'platform', '').toLowerCase();
|
||||
|
||||
if (!platform) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform.includes('mac')) {
|
||||
document.body.classList.add('os-mac');
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform.includes('win')) {
|
||||
document.body.classList.add('os-windows');
|
||||
return;
|
||||
}
|
||||
|
||||
if (platform.includes('linux')) {
|
||||
document.body.classList.add('os-linux');
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
streamDataReceived
|
||||
} 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 toast from 'react-hot-toast';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { isElectron } from 'utils/common/platform';
|
||||
@@ -98,6 +99,68 @@ const useIpcEvents = () => {
|
||||
dispatch(openCollectionEvent(uid, pathname, brunoConfig));
|
||||
});
|
||||
|
||||
const removeOpenWorkspaceListener = ipcRenderer.on('main:workspace-opened', (workspacePath, workspaceUid, workspaceConfig) => {
|
||||
dispatch(workspaceOpenedEvent(workspacePath, workspaceUid, workspaceConfig));
|
||||
});
|
||||
|
||||
const removeWorkspaceConfigUpdatedListener = ipcRenderer.on('main:workspace-config-updated', (workspacePath, workspaceUid, workspaceConfig) => {
|
||||
dispatch(workspaceConfigUpdatedEvent(workspacePath, workspaceUid, workspaceConfig));
|
||||
});
|
||||
|
||||
const removeWorkspaceEnvironmentAddedListener = ipcRenderer.on('main:workspace-environment-added', (workspaceUid, file) => {
|
||||
const state = window.__store__.getState();
|
||||
const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid;
|
||||
if (activeWorkspaceUid === workspaceUid) {
|
||||
const workspace = state.workspaces?.workspaces?.find((w) => w.uid === workspaceUid);
|
||||
if (workspace) {
|
||||
ipcRenderer.invoke('renderer:get-global-environments', {
|
||||
workspaceUid,
|
||||
workspacePath: workspace.pathname
|
||||
}).then((result) => {
|
||||
dispatch(updateGlobalEnvironments(result));
|
||||
}).catch((error) => {
|
||||
console.error('Error refreshing global environments:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const removeWorkspaceEnvironmentChangedListener = ipcRenderer.on('main:workspace-environment-changed', (workspaceUid, file) => {
|
||||
const state = window.__store__.getState();
|
||||
const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid;
|
||||
if (activeWorkspaceUid === workspaceUid) {
|
||||
const workspace = state.workspaces?.workspaces?.find((w) => w.uid === workspaceUid);
|
||||
if (workspace) {
|
||||
ipcRenderer.invoke('renderer:get-global-environments', {
|
||||
workspaceUid,
|
||||
workspacePath: workspace.pathname
|
||||
}).then((result) => {
|
||||
dispatch(updateGlobalEnvironments(result));
|
||||
}).catch((error) => {
|
||||
console.error('Error refreshing global environments:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const removeWorkspaceEnvironmentDeletedListener = ipcRenderer.on('main:workspace-environment-deleted', (workspaceUid, environmentUid) => {
|
||||
const state = window.__store__.getState();
|
||||
const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid;
|
||||
if (activeWorkspaceUid === workspaceUid) {
|
||||
const workspace = state.workspaces?.workspaces?.find((w) => w.uid === workspaceUid);
|
||||
if (workspace) {
|
||||
ipcRenderer.invoke('renderer:get-global-environments', {
|
||||
workspaceUid,
|
||||
workspacePath: workspace.pathname
|
||||
}).then((result) => {
|
||||
dispatch(updateGlobalEnvironments(result));
|
||||
}).catch((error) => {
|
||||
console.error('Error refreshing global environments:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const removeCollectionAlreadyOpenedListener = ipcRenderer.on('main:collection-already-opened', (pathname) => {
|
||||
toast.success('Collection is already opened');
|
||||
});
|
||||
@@ -205,6 +268,11 @@ const useIpcEvents = () => {
|
||||
return () => {
|
||||
removeCollectionTreeUpdateListener();
|
||||
removeOpenCollectionListener();
|
||||
removeOpenWorkspaceListener();
|
||||
removeWorkspaceConfigUpdatedListener();
|
||||
removeWorkspaceEnvironmentAddedListener();
|
||||
removeWorkspaceEnvironmentChangedListener();
|
||||
removeWorkspaceEnvironmentDeletedListener();
|
||||
removeCollectionAlreadyOpenedListener();
|
||||
removeDisplayErrorListener();
|
||||
removeScriptEnvUpdateListener();
|
||||
|
||||
@@ -27,6 +27,7 @@ export const HotkeysProvider = (props) => {
|
||||
const collections = useSelector((state) => state.collections.collections);
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const isEnvironmentSettingsModalOpen = useSelector((state) => state.app.isEnvironmentSettingsModalOpen);
|
||||
const isGlobalEnvironmentSettingsModalOpen = useSelector((state) => state.app.isGlobalEnvironmentSettingsModalOpen);
|
||||
const [showEnvSettingsModal, setShowEnvSettingsModal] = useState(false);
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
|
||||
@@ -43,7 +44,7 @@ export const HotkeysProvider = (props) => {
|
||||
// save hotkey
|
||||
useEffect(() => {
|
||||
Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => {
|
||||
if (isEnvironmentSettingsModalOpen) {
|
||||
if (isEnvironmentSettingsModalOpen || isGlobalEnvironmentSettingsModalOpen) {
|
||||
console.log('todo: save environment settings');
|
||||
} else {
|
||||
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
|
||||
@@ -70,7 +71,7 @@ export const HotkeysProvider = (props) => {
|
||||
return () => {
|
||||
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
|
||||
};
|
||||
}, [activeTabUid, tabs, saveRequest, collections, isEnvironmentSettingsModalOpen]);
|
||||
}, [activeTabUid, tabs, saveRequest, collections, isEnvironmentSettingsModalOpen, isGlobalEnvironmentSettingsModalOpen]);
|
||||
|
||||
// send request (ctrl/cmd + enter)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import notificationsReducer from './slices/notifications';
|
||||
import globalEnvironmentsReducer from './slices/global-environments';
|
||||
import logsReducer from './slices/logs';
|
||||
import performanceReducer from './slices/performance';
|
||||
import workspacesReducer from './slices/workspaces';
|
||||
import { draftDetectMiddleware } from './middlewares/draft/middleware';
|
||||
import { autosaveMiddleware } from './middlewares/autosave/middleware';
|
||||
|
||||
@@ -28,7 +29,8 @@ export const store = configureStore({
|
||||
notifications: notificationsReducer,
|
||||
globalEnvironments: globalEnvironmentsReducer,
|
||||
logs: logsReducer,
|
||||
performance: performanceReducer
|
||||
performance: performanceReducer,
|
||||
workspaces: workspacesReducer
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
|
||||
});
|
||||
|
||||
@@ -5,12 +5,13 @@ import brunoClipboard from 'utils/bruno-clipboard';
|
||||
const initialState = {
|
||||
isDragging: false,
|
||||
idbConnectionReady: false,
|
||||
leftSidebarWidth: 222,
|
||||
leftSidebarWidth: 250,
|
||||
sidebarCollapsed: false,
|
||||
screenWidth: 500,
|
||||
showHomePage: false,
|
||||
showPreferences: false,
|
||||
isEnvironmentSettingsModalOpen: false,
|
||||
isGlobalEnvironmentSettingsModalOpen: false,
|
||||
preferences: {
|
||||
request: {
|
||||
sslVerification: true,
|
||||
@@ -66,6 +67,9 @@ export const appSlice = createSlice({
|
||||
updateEnvironmentSettingsModalVisibility: (state, action) => {
|
||||
state.isEnvironmentSettingsModalOpen = action.payload;
|
||||
},
|
||||
updateGlobalEnvironmentSettingsModalVisibility: (state, action) => {
|
||||
state.isGlobalEnvironmentSettingsModalOpen = action.payload;
|
||||
},
|
||||
showHomePage: (state) => {
|
||||
state.showHomePage = true;
|
||||
},
|
||||
@@ -115,6 +119,7 @@ export const {
|
||||
updateLeftSidebarWidth,
|
||||
updateIsDragging,
|
||||
updateEnvironmentSettingsModalVisibility,
|
||||
updateGlobalEnvironmentSettingsModalVisibility,
|
||||
showHomePage,
|
||||
hideHomePage,
|
||||
showPreferences,
|
||||
|
||||
@@ -7,7 +7,7 @@ import find from 'lodash/find';
|
||||
import get from 'lodash/get';
|
||||
import set from 'lodash/set';
|
||||
import trim from 'lodash/trim';
|
||||
import path from 'utils/common/path';
|
||||
import path, { normalizePath } from 'utils/common/path';
|
||||
import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
|
||||
import { each } from 'lodash';
|
||||
import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
|
||||
import { removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces';
|
||||
import { resolveRequestFilename } from 'utils/common/platform';
|
||||
import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index';
|
||||
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
|
||||
@@ -1127,19 +1128,15 @@ export const handleCollectionItemDrop
|
||||
const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
|
||||
|
||||
const newDirname = path.dirname(newPathname);
|
||||
await dispatch(
|
||||
moveItem({
|
||||
targetDirname: newDirname,
|
||||
sourcePathname: draggedItemPathname
|
||||
})
|
||||
);
|
||||
await dispatch(moveItem({
|
||||
targetDirname: newDirname,
|
||||
sourcePathname: draggedItemPathname
|
||||
}));
|
||||
|
||||
// Update sequences in the source directory
|
||||
if (draggedItemDirectoryItems?.length) {
|
||||
// reorder items in the source directory
|
||||
const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(
|
||||
(i) => i.uid !== draggedItemUid
|
||||
);
|
||||
// reorder items in the source directory
|
||||
const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter((i) => i.uid !== draggedItemUid);
|
||||
const reorderedSourceItems = getReorderedItemsInSourceDirectory({
|
||||
items: draggedItemDirectoryItemsWithoutDraggedItem
|
||||
});
|
||||
@@ -2139,18 +2136,48 @@ export const removeCollection = (collectionUid) => (dispatch, getState) => {
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
// Get active workspace to determine which workspace we're removing from
|
||||
const { workspaces } = state;
|
||||
const activeWorkspace = workspaces.workspaces.find((w) => w.uid === workspaces.activeWorkspaceUid);
|
||||
|
||||
let workspaceId = 'default';
|
||||
if (activeWorkspace) {
|
||||
if (activeWorkspace.pathname) {
|
||||
workspaceId = activeWorkspace.pathname;
|
||||
} else {
|
||||
workspaceId = activeWorkspace.uid;
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:remove-collection', collection.pathname, collectionUid)
|
||||
.invoke('renderer:remove-collection', collection.pathname, collectionUid, workspaceId)
|
||||
.then(() => {
|
||||
dispatch(closeAllCollectionTabs({ collectionUid }));
|
||||
// Check if the collection still exists in other workspaces
|
||||
return ipcRenderer.invoke('renderer:get-collection-workspaces', collection.pathname);
|
||||
})
|
||||
.then(waitForNextTick)
|
||||
.then(() => {
|
||||
dispatch(
|
||||
_removeCollection({
|
||||
collectionUid: collectionUid
|
||||
})
|
||||
);
|
||||
.then((remainingWorkspaces) => {
|
||||
// Close tabs for this collection
|
||||
dispatch(closeAllCollectionTabs({ collectionUid }));
|
||||
|
||||
// Remove collection from workspace in Redux state
|
||||
if (activeWorkspace) {
|
||||
dispatch(removeCollectionFromWorkspace({
|
||||
workspaceUid: activeWorkspace.uid,
|
||||
collectionLocation: collection.pathname
|
||||
}));
|
||||
}
|
||||
|
||||
// Only remove from Redux if no workspaces remain
|
||||
if (!remainingWorkspaces || remainingWorkspaces.length === 0) {
|
||||
return waitForNextTick().then(() => {
|
||||
dispatch(_removeCollection({
|
||||
collectionUid: collectionUid
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
// Collection still exists in other workspaces
|
||||
}
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
@@ -2251,11 +2278,32 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
.validate(collection)
|
||||
.then(() => dispatch(_createCollection({ ...collection, securityConfig })))
|
||||
.then(() => {
|
||||
// Expand sidebar if it's collapsed after collection is successfully opened
|
||||
const state = getState();
|
||||
if (state.app.sidebarCollapsed) {
|
||||
dispatch(toggleSidebarCollapse());
|
||||
}
|
||||
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
if (activeWorkspace) {
|
||||
const isAlreadyInWorkspace = activeWorkspace.collections?.some(
|
||||
(c) => normalizePath(c.path) === normalizePath(pathname)
|
||||
);
|
||||
|
||||
if (!isAlreadyInWorkspace) {
|
||||
const workspaceCollection = {
|
||||
name: brunoConfig.name,
|
||||
path: pathname
|
||||
};
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection)
|
||||
.catch((err) => {
|
||||
console.error('Failed to add collection to workspace', err);
|
||||
toast.error('Failed to add collection to workspace');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
.catch(reject);
|
||||
@@ -2263,12 +2311,23 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
|
||||
});
|
||||
};
|
||||
|
||||
export const createCollection = (collectionName, collectionFolderName, collectionLocation, format = 'bru') => () => {
|
||||
export const createCollection = (collectionName, collectionFolderName, collectionLocation, options = {}) => (dispatch, getState) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
if (!options.workspaceId) {
|
||||
const { workspaces } = getState();
|
||||
const activeWorkspace = workspaces.workspaces.find((w) => w.uid === workspaces.activeWorkspaceUid);
|
||||
|
||||
if (activeWorkspace && activeWorkspace.pathname) {
|
||||
options.workspaceId = activeWorkspace.pathname;
|
||||
} else {
|
||||
options.workspaceId = 'default';
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ipcRenderer
|
||||
.invoke('renderer:create-collection', collectionName, collectionFolderName, collectionLocation, format)
|
||||
.invoke('renderer:create-collection', collectionName, collectionFolderName, collectionLocation, options)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
@@ -2284,11 +2343,34 @@ export const cloneCollection = (collectionName, collectionFolderName, collection
|
||||
previousPath
|
||||
);
|
||||
};
|
||||
export const openCollection = () => () => {
|
||||
export const openCollection = (options = {}) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer.invoke('renderer:open-collection').then(resolve).catch(reject);
|
||||
const state = getState();
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
|
||||
if (!options.workspaceId) {
|
||||
options.workspaceId = activeWorkspace?.pathname || 'default';
|
||||
}
|
||||
|
||||
ipcRenderer.invoke('renderer:open-collection', options)
|
||||
.then((result) => {
|
||||
resolve(result);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const openMultipleCollections = (collectionPaths, options = {}) => () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer.invoke('renderer:open-multiple-collections', collectionPaths, options)
|
||||
.then(resolve)
|
||||
.catch((err) => {
|
||||
reject();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2317,11 +2399,29 @@ export const collectionAddEnvFileEvent = (payload) => (dispatch, getState) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const importCollection = (collection, collectionLocation) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
export const importCollection = (collection, collectionLocation, options = {}) => (dispatch, getState) => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation).then(resolve).catch(reject);
|
||||
try {
|
||||
const state = getState();
|
||||
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
|
||||
|
||||
const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation);
|
||||
|
||||
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
|
||||
const workspaceCollection = {
|
||||
name: collection.name,
|
||||
path: collectionPath
|
||||
};
|
||||
|
||||
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection);
|
||||
}
|
||||
|
||||
resolve(collectionPath);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2329,15 +2429,7 @@ export const moveCollectionAndPersist
|
||||
= ({ draggedItem, targetItem }) =>
|
||||
(dispatch, getState) => {
|
||||
dispatch(moveCollection({ draggedItem, targetItem }));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
|
||||
const collectionPaths = state.collections.collections.map((collection) => collection.pathname);
|
||||
|
||||
ipcRenderer.invoke('renderer:update-collection-paths', collectionPaths).then(resolve).catch(reject);
|
||||
});
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
export const saveCollectionSecurityConfig = (collectionUid, securityConfig) => (dispatch, getState) => {
|
||||
@@ -2534,20 +2626,16 @@ export const openCollectionSettings
|
||||
return reject(new Error('Collection not found'));
|
||||
}
|
||||
|
||||
dispatch(
|
||||
updateSettingsSelectedTab({
|
||||
collectionUid: collection.uid,
|
||||
tab: tabName
|
||||
})
|
||||
);
|
||||
dispatch(updateSettingsSelectedTab({
|
||||
collectionUid: collection.uid,
|
||||
tab: tabName
|
||||
}));
|
||||
|
||||
dispatch(
|
||||
addTab({
|
||||
uid: collection.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-settings'
|
||||
})
|
||||
);
|
||||
dispatch(addTab({
|
||||
uid: collection.uid,
|
||||
collectionUid: collection.uid,
|
||||
type: 'collection-settings'
|
||||
}));
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
||||
@@ -87,19 +87,31 @@ export const {
|
||||
_deleteGlobalEnvironment
|
||||
} = globalEnvironmentsSlice.actions;
|
||||
|
||||
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch) => {
|
||||
const getWorkspaceContext = (state) => {
|
||||
const workspaceUid = state.workspaces?.activeWorkspaceUid;
|
||||
const workspace = state.workspaces?.workspaces?.find((w) => w.uid === workspaceUid);
|
||||
return { workspaceUid, workspacePath: workspace?.pathname };
|
||||
};
|
||||
|
||||
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const uid = uuid();
|
||||
let environment = { name, uid, variables };
|
||||
const environment = { name, uid, variables };
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const { workspaceUid, workspacePath } = getWorkspaceContext(state);
|
||||
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables }))
|
||||
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables, workspaceUid, workspacePath }))
|
||||
.then((result) => {
|
||||
const finalUid = result?.uid || uid;
|
||||
const finalName = result?.name || name;
|
||||
dispatch(_addGlobalEnvironment({ name: finalName, uid, variables }));
|
||||
const finalVariables = result?.variables || variables;
|
||||
dispatch(_addGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables }));
|
||||
return finalUid;
|
||||
})
|
||||
.then(() => dispatch(selectGlobalEnvironment({ environmentUid: uid })))
|
||||
.then((finalUid) => dispatch(selectGlobalEnvironment({ environmentUid: finalUid })))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
@@ -108,17 +120,24 @@ export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch) =>
|
||||
export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const { workspaceUid, workspacePath } = getWorkspaceContext(state);
|
||||
const globalEnvironments = state.globalEnvironments.globalEnvironments;
|
||||
const baseEnv = globalEnvironments?.find((env) => env?.uid == baseEnvUid);
|
||||
if (!baseEnv) {
|
||||
return reject(new Error('Base environment not found'));
|
||||
}
|
||||
const uid = uuid();
|
||||
let environment = { uid, name, variables: baseEnv.variables };
|
||||
const environment = { uid, name, variables: baseEnv.variables };
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables }))
|
||||
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables, workspaceUid, workspacePath }))
|
||||
.then((result) => {
|
||||
const finalUid = result?.uid || uid;
|
||||
const finalName = result?.name || name;
|
||||
dispatch(_copyGlobalEnvironment({ name: finalName, uid, variables: baseEnv.variables }));
|
||||
const finalVariables = result?.variables || baseEnv.variables;
|
||||
dispatch(_copyGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables }));
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
@@ -129,6 +148,7 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const { workspaceUid, workspacePath } = getWorkspaceContext(state);
|
||||
const globalEnvironments = state.globalEnvironments.globalEnvironments;
|
||||
const environment = globalEnvironments?.find((env) => env?.uid == environmentUid);
|
||||
if (!environment) {
|
||||
@@ -136,8 +156,18 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d
|
||||
}
|
||||
environmentSchema
|
||||
.validate(environment)
|
||||
.then(() => ipcRenderer.invoke('renderer:rename-global-environment', { name: newName, environmentUid }))
|
||||
.then(() => dispatch(_renameGlobalEnvironment({ name: newName, environmentUid })))
|
||||
.then(() => ipcRenderer.invoke('renderer:rename-global-environment', { name: newName, environmentUid, workspaceUid, workspacePath }))
|
||||
.then((result) => {
|
||||
const resolvedUid = result?.uid || environmentUid;
|
||||
dispatch(_renameGlobalEnvironment({ name: newName, environmentUid: resolvedUid }));
|
||||
return ipcRenderer
|
||||
.invoke('renderer:get-global-environments', { workspaceUid, workspacePath })
|
||||
.then((data) => {
|
||||
dispatch(updateGlobalEnvironments(data));
|
||||
return resolvedUid;
|
||||
});
|
||||
})
|
||||
.then((resolvedUid) => dispatch(_selectGlobalEnvironment({ environmentUid: resolvedUid })))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
@@ -146,35 +176,47 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d
|
||||
export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = getState();
|
||||
const { workspaceUid, workspacePath } = getWorkspaceContext(state);
|
||||
const globalEnvironments = state.globalEnvironments.globalEnvironments;
|
||||
const environment = globalEnvironments?.find((env) => env?.uid == environmentUid);
|
||||
let environment = globalEnvironments?.find((env) => env?.uid == environmentUid);
|
||||
if (!environment) {
|
||||
const activeUid = state.globalEnvironments?.activeGlobalEnvironmentUid;
|
||||
const activeEnv = globalEnvironments?.find((env) => env?.uid == activeUid);
|
||||
if (activeEnv) {
|
||||
environment = activeEnv;
|
||||
environmentUid = activeEnv.uid;
|
||||
}
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
return reject(new Error('Environment not found'));
|
||||
}
|
||||
|
||||
let environmentToSave = { ...environment, variables };
|
||||
|
||||
const environmentToSave = { ...environment, variables };
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
environmentSchema
|
||||
.validate(environmentToSave)
|
||||
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
|
||||
environmentUid,
|
||||
variables
|
||||
variables,
|
||||
workspaceUid,
|
||||
workspacePath
|
||||
}))
|
||||
.then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables })))
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const { workspaceUid, workspacePath } = getWorkspaceContext(state);
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:select-global-environment', { environmentUid })
|
||||
.invoke('renderer:select-global-environment', { environmentUid, workspaceUid, workspacePath })
|
||||
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
@@ -184,8 +226,11 @@ export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
|
||||
export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { ipcRenderer } = window;
|
||||
const state = getState();
|
||||
const { workspaceUid, workspacePath } = getWorkspaceContext(state);
|
||||
|
||||
ipcRenderer
|
||||
.invoke('renderer:delete-global-environment', { environmentUid })
|
||||
.invoke('renderer:delete-global-environment', { environmentUid, workspaceUid, workspacePath })
|
||||
.then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
@@ -198,6 +243,7 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
|
||||
if (!globalEnvironmentVariables) resolve();
|
||||
|
||||
const state = getState();
|
||||
const { workspaceUid, workspacePath } = getWorkspaceContext(state);
|
||||
const globalEnvironments = state?.globalEnvironments?.globalEnvironments || [];
|
||||
const environmentUid = state?.globalEnvironments?.activeGlobalEnvironmentUid;
|
||||
const environment = globalEnvironments?.find((env) => env?.uid == environmentUid);
|
||||
@@ -217,9 +263,8 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
|
||||
: variable?.value
|
||||
}));
|
||||
|
||||
// add new env values
|
||||
Object.entries(globalEnvironmentVariables)?.forEach?.(([key, value]) => {
|
||||
let isAnExistingVariable = variables?.find((v) => v?.name == key);
|
||||
const isAnExistingVariable = variables?.find((v) => v?.name == key);
|
||||
if (!isAnExistingVariable) {
|
||||
variables.push({
|
||||
uid: uuid(),
|
||||
@@ -232,19 +277,19 @@ export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) =>
|
||||
}
|
||||
});
|
||||
|
||||
let environmentToSave = { ...environment, variables };
|
||||
const environmentToSave = { ...environment, variables };
|
||||
|
||||
environmentSchema
|
||||
.validate(environmentToSave)
|
||||
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
|
||||
environmentUid,
|
||||
variables
|
||||
variables,
|
||||
workspaceUid,
|
||||
workspacePath
|
||||
}))
|
||||
.then(() => dispatch(_saveGlobalEnvironment({ environmentUid, variables })))
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
.catch(reject);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
import path from 'path';
|
||||
import {
|
||||
createWorkspace,
|
||||
removeWorkspace,
|
||||
setActiveWorkspace,
|
||||
updateWorkspace,
|
||||
addCollectionToWorkspace,
|
||||
removeCollectionFromWorkspace,
|
||||
updateWorkspaceLoadingState
|
||||
} from '../workspaces';
|
||||
import { showHomePage } from '../app';
|
||||
import { createCollection, openCollection, openMultipleCollections } from '../collections/actions';
|
||||
import { removeCollection } from '../collections';
|
||||
import { updateGlobalEnvironments } from '../global-environments';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const transformCollection = async (collection, type) => {
|
||||
switch (type) {
|
||||
case 'bruno': {
|
||||
const { processBrunoCollection } = await import('utils/importers/bruno-collection');
|
||||
return processBrunoCollection(collection);
|
||||
}
|
||||
case 'postman': {
|
||||
const { postmanToBruno } = await import('utils/importers/postman-collection');
|
||||
return postmanToBruno(collection);
|
||||
}
|
||||
case 'insomnia': {
|
||||
const { convertInsomniaToBruno } = await import('utils/importers/insomnia-collection');
|
||||
return convertInsomniaToBruno(collection);
|
||||
}
|
||||
case 'openapi': {
|
||||
const { convertOpenapiToBruno } = await import('utils/importers/openapi-collection');
|
||||
return convertOpenapiToBruno(collection);
|
||||
}
|
||||
case 'wsdl': {
|
||||
const { wsdlToBruno } = await import('@usebruno/converters');
|
||||
return wsdlToBruno(collection);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported collection type: ${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const createWorkspaceAction = (workspaceName, workspaceFolderName, workspaceLocation) => {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('renderer:create-workspace',
|
||||
workspaceName,
|
||||
workspaceFolderName,
|
||||
workspaceLocation);
|
||||
|
||||
const { workspaceConfig, workspaceUid, workspacePath } = result;
|
||||
|
||||
dispatch(createWorkspace({
|
||||
uid: workspaceUid,
|
||||
name: workspaceName,
|
||||
pathname: workspacePath,
|
||||
...workspaceConfig
|
||||
}));
|
||||
|
||||
await dispatch(switchWorkspace(workspaceUid));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const openWorkspace = () => {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const workspacePath = await ipcRenderer.invoke('renderer:browse-directory');
|
||||
if (workspacePath) {
|
||||
const result = await ipcRenderer.invoke('renderer:open-workspace', workspacePath);
|
||||
const { workspaceConfig, workspaceUid } = result;
|
||||
|
||||
dispatch(createWorkspace({
|
||||
uid: workspaceUid,
|
||||
pathname: workspacePath,
|
||||
...workspaceConfig
|
||||
}));
|
||||
|
||||
await dispatch(switchWorkspace(workspaceUid));
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const openWorkspaceDialog = () => {
|
||||
return async (dispatch) => {
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('renderer:open-workspace-dialog');
|
||||
if (result) {
|
||||
const { workspaceConfig, workspaceUid } = result;
|
||||
|
||||
dispatch(createWorkspace({
|
||||
uid: workspaceUid,
|
||||
pathname: result.workspacePath,
|
||||
...workspaceConfig
|
||||
}));
|
||||
|
||||
await dispatch(switchWorkspace(workspaceUid));
|
||||
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspacesState = getState().workspaces;
|
||||
const collectionsState = getState().collections;
|
||||
const workspace = workspacesState.workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const normalizedCollectionPath = normalizePath(collectionPath);
|
||||
|
||||
const collection = collectionsState.collections.find(
|
||||
(c) => normalizePath(c.pathname) === normalizedCollectionPath
|
||||
);
|
||||
|
||||
await ipcRenderer.invoke('renderer:remove-collection-from-workspace',
|
||||
workspaceUid,
|
||||
workspace.pathname,
|
||||
collectionPath);
|
||||
|
||||
if (collection) {
|
||||
const workspaceCollection = workspace.collections?.find(
|
||||
(wc) => normalizePath(wc.path) === normalizedCollectionPath
|
||||
);
|
||||
|
||||
if (workspaceCollection) {
|
||||
dispatch(removeCollection({ collectionUid: collection.uid }));
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(removeCollectionFromWorkspace({
|
||||
workspaceUid,
|
||||
collectionLocation: collectionPath
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => {
|
||||
const openCollectionsFunction = (collectionPaths, workspaceId) => {
|
||||
return dispatch(openMultipleCollections(collectionPaths, { workspaceId }));
|
||||
};
|
||||
|
||||
try {
|
||||
await dispatch(loadWorkspaceCollections(workspace.uid));
|
||||
const updatedWorkspace = await dispatch((_, getState) => getState().workspaces.workspaces.find((w) => w.uid === workspace.uid));
|
||||
|
||||
if (updatedWorkspace?.collections?.length > 0) {
|
||||
const alreadyOpenCollections = await dispatch((_, getState) =>
|
||||
getState().collections.collections.map((c) => normalizePath(c.pathname))
|
||||
);
|
||||
|
||||
const collectionPaths = updatedWorkspace.collections
|
||||
.map((wc) => wc.path)
|
||||
.filter((p) => p && !alreadyOpenCollections.includes(normalizePath(p)));
|
||||
|
||||
if (collectionPaths.length > 0) {
|
||||
await openCollectionsFunction(collectionPaths, updatedWorkspace.pathname);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspace collections:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export const switchWorkspace = (workspaceUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch(setActiveWorkspace(workspaceUid));
|
||||
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const result = await ipcRenderer.invoke('renderer:get-global-environments',
|
||||
{
|
||||
workspaceUid,
|
||||
workspacePath: workspace.pathname
|
||||
});
|
||||
|
||||
const globalEnvironments = result?.globalEnvironments || [];
|
||||
const activeGlobalEnvironmentUid = result?.activeGlobalEnvironmentUid || null;
|
||||
|
||||
dispatch(updateGlobalEnvironments({ globalEnvironments, activeGlobalEnvironmentUid }));
|
||||
} catch (error) {
|
||||
dispatch(updateGlobalEnvironments({ globalEnvironments: [], activeGlobalEnvironmentUid: null }));
|
||||
}
|
||||
|
||||
await loadWorkspaceCollectionsForSwitch(dispatch, workspace);
|
||||
dispatch(showHomePage());
|
||||
};
|
||||
};
|
||||
|
||||
export const loadWorkspaceCollections = (workspaceUid, force = false) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const hasProcessedCollections = workspace.collections
|
||||
&& workspace.collections.length > 0
|
||||
&& workspace.collections.some((c) => c.path && path.isAbsolute(c.path));
|
||||
|
||||
if (!force && hasProcessedCollections) {
|
||||
return workspace.collections;
|
||||
}
|
||||
|
||||
dispatch(updateWorkspaceLoadingState({ workspaceUid, loadingState: 'loading' }));
|
||||
|
||||
let collections = [];
|
||||
|
||||
if (!workspace.pathname) {
|
||||
collections = [];
|
||||
} else {
|
||||
const rawCollections = await ipcRenderer.invoke('renderer:load-workspace-collections', workspace.pathname);
|
||||
|
||||
collections = rawCollections.map((collection) => {
|
||||
return {
|
||||
...collection
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
dispatch(updateWorkspace({
|
||||
uid: workspaceUid,
|
||||
collections
|
||||
}));
|
||||
|
||||
dispatch(updateWorkspaceLoadingState({ workspaceUid, loadingState: 'loaded' }));
|
||||
|
||||
return collections;
|
||||
} catch (error) {
|
||||
dispatch(updateWorkspaceLoadingState({ workspaceUid, loadingState: 'error' }));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const removeWorkspaceAction = (workspaceUid) => {
|
||||
return (dispatch) => {
|
||||
dispatch(removeWorkspace(workspaceUid));
|
||||
};
|
||||
};
|
||||
|
||||
export const loadLastOpenedWorkspaces = () => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspaces = await ipcRenderer.invoke('renderer:get-last-opened-workspaces');
|
||||
const currentWorkspaces = getState().workspaces.workspaces;
|
||||
const validWorkspaceUids = new Set(workspaces.map((w) => w.uid));
|
||||
|
||||
for (const currentWorkspace of currentWorkspaces) {
|
||||
if (currentWorkspace.type !== 'default' && !validWorkspaceUids.has(currentWorkspace.uid)) {
|
||||
dispatch(removeWorkspace(currentWorkspace.uid));
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
const existingWorkspace = currentWorkspaces.find((w) => w.uid === workspace.uid);
|
||||
|
||||
if (!existingWorkspace) {
|
||||
dispatch(createWorkspace(workspace));
|
||||
|
||||
if (workspace.pathname) {
|
||||
try {
|
||||
await ipcRenderer.invoke('renderer:start-workspace-watcher', workspace.pathname);
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return workspaces;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const workspaceOpenedEvent = (workspacePath, workspaceUid, workspaceConfig) => {
|
||||
return async (dispatch, getState) => {
|
||||
dispatch(createWorkspace({
|
||||
uid: workspaceUid,
|
||||
pathname: workspacePath,
|
||||
...workspaceConfig
|
||||
}));
|
||||
|
||||
try {
|
||||
await dispatch(loadWorkspaceCollections(workspaceUid));
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
// If this is the default workspace or no workspace is active yet, switch to it
|
||||
const state = getState();
|
||||
const activeWorkspaceUid = state.workspaces.activeWorkspaceUid;
|
||||
|
||||
if (!activeWorkspaceUid || workspaceConfig.type === 'default') {
|
||||
dispatch(switchWorkspace(workspaceUid));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspaceConfig) => {
|
||||
return async (dispatch, getState) => {
|
||||
if (!workspaceConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { collections, ...configWithoutCollections } = workspaceConfig;
|
||||
|
||||
dispatch(updateWorkspace({
|
||||
uid: workspaceUid,
|
||||
...configWithoutCollections
|
||||
}));
|
||||
|
||||
const activeWorkspaceUid = getState().workspaces.activeWorkspaceUid;
|
||||
if (activeWorkspaceUid === workspaceUid) {
|
||||
try {
|
||||
await dispatch(loadWorkspaceCollections(workspaceUid, true));
|
||||
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
const openCollections = getState().collections.collections.map((c) => normalizePath(c.pathname));
|
||||
|
||||
if (workspace?.collections?.length > 0) {
|
||||
const newCollectionPaths = workspace.collections
|
||||
.map((workspaceCollection) => workspaceCollection.path)
|
||||
.filter((collectionPath) => collectionPath && !openCollections.includes(normalizePath(collectionPath)));
|
||||
|
||||
if (newCollectionPaths.length > 0) {
|
||||
try {
|
||||
await dispatch(openMultipleCollections(newCollectionPaths, { workspaceId: workspace.pathname }));
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const saveWorkspaceDocs = (workspaceUid, docs) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
if (workspace.type === 'default' || !workspace.pathname) {
|
||||
await ipcRenderer.invoke('renderer:save-preferences', {
|
||||
defaultWorkspaceDocs: docs || ''
|
||||
});
|
||||
} else {
|
||||
await ipcRenderer.invoke('renderer:save-workspace-docs', workspace.pathname, docs || '');
|
||||
}
|
||||
|
||||
dispatch(updateWorkspace({
|
||||
uid: workspaceUid,
|
||||
docs: docs
|
||||
}));
|
||||
|
||||
return docs;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createCollectionInWorkspace = (collectionName, collectionFolderName, collectionLocation, workspaceUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
const currentWorkspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!currentWorkspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const projectCollectionLocation = `${currentWorkspace.pathname}/collections`;
|
||||
|
||||
return await dispatch(createCollection(collectionName, collectionFolderName, projectCollectionLocation, {
|
||||
workspaceId: currentWorkspace.pathname
|
||||
}));
|
||||
};
|
||||
};
|
||||
|
||||
export const openCollectionInWorkspace = () => {
|
||||
return (dispatch) => dispatch(openCollection());
|
||||
};
|
||||
|
||||
const handleWorkspaceAction = async (action, workspaceUid, ...args) => {
|
||||
try {
|
||||
await action(workspaceUid, ...args);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const actionName = action.name.replace('renderer:', '').replace('-', ' ');
|
||||
toast.error(error.message || `Failed to ${actionName} workspace`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const renameWorkspaceAction = (workspaceUid, newName) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const { workspaces } = getState().workspaces;
|
||||
const workspace = workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
await handleWorkspaceAction((...args) => ipcRenderer.invoke('renderer:rename-workspace', ...args),
|
||||
workspace.pathname,
|
||||
newName);
|
||||
|
||||
dispatch(updateWorkspace({
|
||||
uid: workspaceUid,
|
||||
name: newName
|
||||
}));
|
||||
|
||||
toast.success('Workspace renamed successfully');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const closeWorkspaceAction = (workspaceUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const { workspaces } = getState().workspaces;
|
||||
const workspace = workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke('renderer:close-workspace', workspace.pathname);
|
||||
dispatch(removeWorkspace(workspaceUid));
|
||||
|
||||
toast.success('Workspace closed successfully');
|
||||
} catch (error) {
|
||||
toast.error(error.message || 'Failed to close workspace');
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const importCollectionInWorkspace = (collection, workspaceUid, collectionLocation, type) => {
|
||||
return async (dispatch, getState) => {
|
||||
const currentWorkspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
|
||||
if (!currentWorkspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const location = collectionLocation || path.join(currentWorkspace.pathname, 'collections');
|
||||
const transformedCollection = await transformCollection(collection, type);
|
||||
const collectionPath = await ipcRenderer.invoke('renderer:import-collection', transformedCollection, location);
|
||||
|
||||
const workspaceCollection = {
|
||||
name: transformedCollection.name,
|
||||
path: collectionPath
|
||||
};
|
||||
|
||||
await ipcRenderer.invoke('renderer:add-collection-to-workspace', currentWorkspace.pathname, workspaceCollection);
|
||||
|
||||
return collectionPath;
|
||||
};
|
||||
};
|
||||
|
||||
export const loadWorkspaceEnvironments = (workspaceUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const environments = await ipcRenderer.invoke('renderer:load-workspace-environments', workspace.pathname);
|
||||
|
||||
dispatch(updateWorkspace({
|
||||
uid: workspaceUid,
|
||||
environments: environments
|
||||
}));
|
||||
|
||||
return environments;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const createWorkspaceEnvironment = (workspaceUid, environmentName) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const environment = await ipcRenderer.invoke('renderer:create-workspace-environment', workspace.pathname, environmentName);
|
||||
|
||||
await dispatch(loadWorkspaceEnvironments(workspaceUid));
|
||||
|
||||
return environment;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteWorkspaceEnvironment = (workspaceUid, environmentUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke('renderer:delete-workspace-environment', workspace.pathname, environmentUid);
|
||||
|
||||
await dispatch(loadWorkspaceEnvironments(workspaceUid));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const selectWorkspaceEnvironment = (workspaceUid, environmentUid) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke('renderer:select-workspace-environment', workspace.pathname, environmentUid);
|
||||
|
||||
dispatch(updateWorkspace({
|
||||
uid: workspaceUid,
|
||||
activeEnvironmentUid: environmentUid
|
||||
}));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const importWorkspaceEnvironment = (workspaceUid, environmentData) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const environment = await ipcRenderer.invoke('renderer:import-workspace-environment', workspace.pathname, environmentData);
|
||||
|
||||
await dispatch(loadWorkspaceEnvironments(workspaceUid));
|
||||
|
||||
return environment;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const updateWorkspaceEnvironment = (workspaceUid, environmentUid, environmentData) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke('renderer:update-workspace-environment', workspace.pathname, environmentUid, environmentData);
|
||||
|
||||
await dispatch(loadWorkspaceEnvironments(workspaceUid));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const renameWorkspaceEnvironment = (workspaceUid, environmentUid, newName) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
await ipcRenderer.invoke('renderer:rename-workspace-environment', workspace.pathname, environmentUid, newName);
|
||||
|
||||
await dispatch(loadWorkspaceEnvironments(workspaceUid));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const copyWorkspaceEnvironment = (workspaceUid, environmentUid, newName) => {
|
||||
return async (dispatch, getState) => {
|
||||
try {
|
||||
const workspace = getState().workspaces.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (!workspace) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const newEnvironment = await ipcRenderer.invoke('renderer:copy-workspace-environment', workspace.pathname, environmentUid, newName);
|
||||
|
||||
await dispatch(loadWorkspaceEnvironments(workspaceUid));
|
||||
|
||||
return newEnvironment;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
|
||||
const DEFAULT_WORKSPACE_UID = 'default';
|
||||
|
||||
const initialState = {
|
||||
workspaces: [],
|
||||
activeWorkspaceUid: DEFAULT_WORKSPACE_UID
|
||||
};
|
||||
|
||||
export const workspacesSlice = createSlice({
|
||||
name: 'workspaces',
|
||||
initialState,
|
||||
reducers: {
|
||||
setActiveWorkspace: (state, action) => {
|
||||
state.activeWorkspaceUid = action.payload;
|
||||
},
|
||||
|
||||
createWorkspace: (state, action) => {
|
||||
const workspace = action.payload;
|
||||
workspace.collections = workspace.collections || [];
|
||||
|
||||
const existingWorkspace = state.workspaces.find((w) => w.uid === workspace.uid);
|
||||
if (!existingWorkspace) {
|
||||
state.workspaces.push(workspace);
|
||||
} else {
|
||||
Object.assign(existingWorkspace, workspace);
|
||||
}
|
||||
},
|
||||
|
||||
removeWorkspace: (state, action) => {
|
||||
const workspaceUid = action.payload;
|
||||
state.workspaces = state.workspaces.filter((w) => w.uid !== workspaceUid);
|
||||
|
||||
if (state.activeWorkspaceUid === workspaceUid) {
|
||||
state.activeWorkspaceUid = DEFAULT_WORKSPACE_UID;
|
||||
}
|
||||
},
|
||||
|
||||
updateWorkspace: (state, action) => {
|
||||
const { uid, ...updates } = action.payload;
|
||||
const workspace = state.workspaces.find((w) => w.uid === uid);
|
||||
if (workspace) {
|
||||
Object.assign(workspace, updates);
|
||||
}
|
||||
},
|
||||
|
||||
addCollectionToWorkspace: (state, action) => {
|
||||
const { workspaceUid, collection } = action.payload;
|
||||
const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (workspace) {
|
||||
workspace.collections = workspace.collections || [];
|
||||
const existingCollection = workspace.collections.find((c) =>
|
||||
c.uid === collection.uid || c.path === collection.path);
|
||||
if (!existingCollection) {
|
||||
workspace.collections.push(collection);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
removeCollectionFromWorkspace: (state, action) => {
|
||||
const { workspaceUid, collectionLocation } = action.payload;
|
||||
const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (workspace?.collections) {
|
||||
const normalizedLocation = normalizePath(collectionLocation);
|
||||
workspace.collections = workspace.collections.filter((c) => {
|
||||
const normalizedPath = normalizePath(c.path);
|
||||
return normalizedPath !== normalizedLocation;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
updateWorkspaceLoadingState: (state, action) => {
|
||||
const { workspaceUid, loadingState } = action.payload;
|
||||
const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
|
||||
if (workspace) {
|
||||
workspace.loadingState = loadingState;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const {
|
||||
setActiveWorkspace,
|
||||
createWorkspace,
|
||||
removeWorkspace,
|
||||
updateWorkspace,
|
||||
addCollectionToWorkspace,
|
||||
removeCollectionFromWorkspace,
|
||||
updateWorkspaceLoadingState
|
||||
} = workspacesSlice.actions;
|
||||
|
||||
export default workspacesSlice.reducer;
|
||||
@@ -36,7 +36,11 @@ export const ThemeProvider = (props) => {
|
||||
setDisplayedTheme(storedTheme);
|
||||
root.classList.add(storedTheme);
|
||||
}
|
||||
}, [storedTheme, setDisplayedTheme, window.matchMedia]);
|
||||
|
||||
if (window.ipcRenderer) {
|
||||
window.ipcRenderer.send('renderer:theme-change', storedTheme);
|
||||
}
|
||||
}, [storedTheme]);
|
||||
|
||||
// storedTheme can have 3 values: 'light', 'dark', 'system'
|
||||
// displayedTheme can have 2 values: 'light', 'dark'
|
||||
|
||||
@@ -149,6 +149,39 @@ const darkTheme = {
|
||||
headingText: '#FFFFFF'
|
||||
},
|
||||
|
||||
listItem: {
|
||||
hoverBg: '#2A2D2F',
|
||||
activeBg: '#3D3D3D'
|
||||
},
|
||||
|
||||
workspace: {
|
||||
accent: '#F59E0B',
|
||||
border: '#444',
|
||||
borderMuted: '#585858',
|
||||
card: {
|
||||
bg: '#2A2D2F'
|
||||
},
|
||||
button: {
|
||||
bg: '#242424'
|
||||
},
|
||||
collection: {
|
||||
header: {
|
||||
indentBorder: 'solid 1px #444444'
|
||||
},
|
||||
item: {
|
||||
indentBorder: 'solid 1px #313131'
|
||||
}
|
||||
},
|
||||
environments: {
|
||||
bg: '#212121',
|
||||
indentBorder: 'solid 1px #313131',
|
||||
activeBg: '#37373c',
|
||||
search: {
|
||||
bg: '#3D3D3D'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
request: {
|
||||
methods: {
|
||||
get: '#8cd656',
|
||||
|
||||
@@ -152,6 +152,39 @@ const lightTheme = {
|
||||
headingText: '#343434'
|
||||
},
|
||||
|
||||
listItem: {
|
||||
hoverBg: '#e7e7e7',
|
||||
activeBg: '#dcdcdc'
|
||||
},
|
||||
|
||||
workspace: {
|
||||
accent: '#D97706',
|
||||
border: '#e7e7e7',
|
||||
borderMuted: '#f3f3f3',
|
||||
card: {
|
||||
bg: '#fff'
|
||||
},
|
||||
button: {
|
||||
bg: '#f3f3f3'
|
||||
},
|
||||
collection: {
|
||||
header: {
|
||||
indentBorder: 'solid 1px #efefef'
|
||||
},
|
||||
item: {
|
||||
indentBorder: 'solid 1px #f9f9f9'
|
||||
}
|
||||
},
|
||||
environments: {
|
||||
bg: '#fbfbfb',
|
||||
indentBorder: 'solid 1px #efefef',
|
||||
activeBg: '#eeeeee',
|
||||
search: {
|
||||
bg: '#fff'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
request: {
|
||||
methods: {
|
||||
get: 'rgb(5, 150, 105)',
|
||||
|
||||
@@ -338,3 +338,23 @@ export const prettifyJsonString = (jsonDataString) => {
|
||||
}
|
||||
return jsonDataString;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the given string value converted to title case.
|
||||
* - If the value is falsy, returns an empty string.
|
||||
* - Special-case: if the value is 'default', returns 'Default'.
|
||||
* - Otherwise, splits the string on whitespace, hyphens, or underscores,
|
||||
* uppercases the first letter of each word, and lowercases the rest.
|
||||
*
|
||||
* @param {string} str - The input string to convert.
|
||||
* @returns {string} - The converted title-case string.
|
||||
*/
|
||||
|
||||
export const toTitleCase = (str) => {
|
||||
if (!str) return '';
|
||||
if (str === 'default') return 'Default';
|
||||
return str
|
||||
.split(/[\s-_]+/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
@@ -163,5 +163,10 @@ const getAbsoluteFilePath = (basePath, relativePath, shouldPosixify = false) =>
|
||||
return shouldPosixify ? posixify(result) : result;
|
||||
};
|
||||
|
||||
const normalizePath = (p) => {
|
||||
if (!p) return '';
|
||||
return p.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
export default brunoPath;
|
||||
export { getRelativePath, getBasename, getAbsoluteFilePath };
|
||||
export { getRelativePath, getBasename, getAbsoluteFilePath, normalizePath };
|
||||
|
||||
119
packages/bruno-app/src/utils/workspaces/index.js
Normal file
119
packages/bruno-app/src/utils/workspaces/index.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// Utility functions for workspace pinning and reordering
|
||||
|
||||
export const sortWorkspaces = (workspaces, preferences) => {
|
||||
const pinnedUids = preferences?.workspaces?.pinnedWorkspaceUids || [];
|
||||
const pinnedOrder = preferences?.workspaces?.pinnedOrder || [];
|
||||
const unpinnedOrder = preferences?.workspaces?.unpinnedOrder || [];
|
||||
|
||||
const defaultWs = workspaces.find((w) => w.type === 'default');
|
||||
const pinnedWs = workspaces.filter((w) => w.type !== 'default' && pinnedUids.includes(w.uid));
|
||||
const unpinnedWs = workspaces.filter((w) => w.type !== 'default' && !pinnedUids.includes(w.uid));
|
||||
|
||||
const sortedPinned = [...pinnedWs].sort((a, b) => {
|
||||
const aIndex = pinnedOrder.indexOf(a.uid);
|
||||
const bIndex = pinnedOrder.indexOf(b.uid);
|
||||
|
||||
if (aIndex !== -1 && bIndex !== -1) {
|
||||
return aIndex - bIndex;
|
||||
}
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
|
||||
const sortedUnpinned = [...unpinnedWs].sort((a, b) => {
|
||||
const aIndex = unpinnedOrder.indexOf(a.uid);
|
||||
const bIndex = unpinnedOrder.indexOf(b.uid);
|
||||
|
||||
if (aIndex !== -1 && bIndex !== -1) {
|
||||
return aIndex - bIndex;
|
||||
}
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
|
||||
// Combine: default -> pinned -> unpinned
|
||||
return [
|
||||
...(defaultWs ? [defaultWs] : []),
|
||||
...sortedPinned,
|
||||
...sortedUnpinned
|
||||
];
|
||||
};
|
||||
|
||||
export const toggleWorkspacePin = (workspaceUid, preferences) => {
|
||||
const pinnedUids = preferences?.workspaces?.pinnedWorkspaceUids || [];
|
||||
const pinnedOrder = preferences?.workspaces?.pinnedOrder || [];
|
||||
const unpinnedOrder = preferences?.workspaces?.unpinnedOrder || [];
|
||||
|
||||
const isPinned = pinnedUids.includes(workspaceUid);
|
||||
|
||||
if (isPinned) {
|
||||
return {
|
||||
...preferences,
|
||||
workspaces: {
|
||||
...preferences.workspaces,
|
||||
pinnedWorkspaceUids: pinnedUids.filter((uid) => uid !== workspaceUid),
|
||||
pinnedOrder: pinnedOrder.filter((uid) => uid !== workspaceUid),
|
||||
unpinnedOrder: [...unpinnedOrder, workspaceUid]
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...preferences,
|
||||
workspaces: {
|
||||
...(preferences?.workspaces || {}),
|
||||
pinnedWorkspaceUids: [...pinnedUids, workspaceUid],
|
||||
pinnedOrder: [...pinnedOrder, workspaceUid],
|
||||
unpinnedOrder: unpinnedOrder.filter((uid) => uid !== workspaceUid)
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const reorderWorkspaces = (draggedUid, targetUid, dropPosition, preferences) => {
|
||||
const pinnedUids = preferences?.workspaces?.pinnedWorkspaceUids || [];
|
||||
const pinnedOrder = preferences?.workspaces?.pinnedOrder || [];
|
||||
const unpinnedOrder = preferences?.workspaces?.unpinnedOrder || [];
|
||||
|
||||
const isDraggedPinned = pinnedUids.includes(draggedUid);
|
||||
const isTargetPinned = pinnedUids.includes(targetUid);
|
||||
|
||||
if (isDraggedPinned !== isTargetPinned) {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
const orderArray = isDraggedPinned ? [...pinnedOrder] : [...unpinnedOrder];
|
||||
|
||||
const filteredOrder = orderArray.filter((uid) => uid !== draggedUid);
|
||||
|
||||
let targetIndex = filteredOrder.indexOf(targetUid);
|
||||
|
||||
if (targetIndex === -1) {
|
||||
filteredOrder.push(targetUid);
|
||||
targetIndex = filteredOrder.length - 1;
|
||||
}
|
||||
|
||||
const insertIndex = dropPosition === 'after' ? targetIndex + 1 : targetIndex;
|
||||
filteredOrder.splice(insertIndex, 0, draggedUid);
|
||||
|
||||
if (isDraggedPinned) {
|
||||
return {
|
||||
...preferences,
|
||||
workspaces: {
|
||||
...(preferences?.workspaces || {}),
|
||||
pinnedOrder: filteredOrder
|
||||
}
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...preferences,
|
||||
workspaces: {
|
||||
...(preferences?.workspaces || {}),
|
||||
unpinnedOrder: filteredOrder
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -575,7 +575,7 @@ const handler = async function (argv) {
|
||||
|
||||
results.push({
|
||||
...result,
|
||||
runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
|
||||
runDuration: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
|
||||
suitename: pathname.replace('.bru', ''),
|
||||
name
|
||||
});
|
||||
|
||||
@@ -11,8 +11,10 @@ const makeJUnitOutput = async (results, outputPath) => {
|
||||
|
||||
results.forEach((result) => {
|
||||
const assertionTestCount = result.assertionResults ? result.assertionResults.length : 0;
|
||||
const preRequestTestCount = result.preRequestTestResults ? result.preRequestTestResults.length : 0;
|
||||
const testCount = result.testResults ? result.testResults.length : 0;
|
||||
const totalTests = assertionTestCount + testCount;
|
||||
const postResponseTestCount = result.postResponseTestResults ? result.postResponseTestResults.length : 0;
|
||||
const totalTests = assertionTestCount + preRequestTestCount + testCount + postResponseTestCount;
|
||||
|
||||
const suite = {
|
||||
'@name': result.name,
|
||||
@@ -22,7 +24,7 @@ const makeJUnitOutput = async (results, outputPath) => {
|
||||
'@tests': totalTests,
|
||||
'@timestamp': new Date().toISOString().split('Z')[0],
|
||||
'@hostname': os.hostname(),
|
||||
'@time': result.runtime.toFixed(3),
|
||||
'@time': result.runDuration.toFixed(3),
|
||||
'testcase': []
|
||||
};
|
||||
|
||||
@@ -32,7 +34,7 @@ const makeJUnitOutput = async (results, outputPath) => {
|
||||
'@name': `${assertion.lhsExpr} ${assertion.rhsExpr}`,
|
||||
'@status': assertion.status,
|
||||
'@classname': result.request.url,
|
||||
'@time': (result.runtime / totalTests).toFixed(3)
|
||||
'@time': (result.runDuration / totalTests).toFixed(3)
|
||||
};
|
||||
|
||||
if (assertion.status === 'fail') {
|
||||
@@ -44,13 +46,49 @@ const makeJUnitOutput = async (results, outputPath) => {
|
||||
suite.testcase.push(testcase);
|
||||
});
|
||||
|
||||
result.preRequestTestResults
|
||||
&& result.preRequestTestResults.forEach((test) => {
|
||||
const testcase = {
|
||||
'@name': test.description,
|
||||
'@status': test.status,
|
||||
'@classname': result.request.url,
|
||||
'@time': (result.runDuration / totalTests).toFixed(3)
|
||||
};
|
||||
|
||||
if (test.status === 'fail') {
|
||||
suite['@failures']++;
|
||||
|
||||
testcase.failure = [{ '@type': 'failure', '@message': test.error }];
|
||||
}
|
||||
|
||||
suite.testcase.push(testcase);
|
||||
});
|
||||
|
||||
result.testResults
|
||||
&& result.testResults.forEach((test) => {
|
||||
const testcase = {
|
||||
'@name': test.description,
|
||||
'@status': test.status,
|
||||
'@classname': result.request.url,
|
||||
'@time': (result.runtime / totalTests).toFixed(3)
|
||||
'@time': (result.runDuration / totalTests).toFixed(3)
|
||||
};
|
||||
|
||||
if (test.status === 'fail') {
|
||||
suite['@failures']++;
|
||||
|
||||
testcase.failure = [{ '@type': 'failure', '@message': test.error }];
|
||||
}
|
||||
|
||||
suite.testcase.push(testcase);
|
||||
});
|
||||
|
||||
result.postResponseTestResults
|
||||
&& result.postResponseTestResults.forEach((test) => {
|
||||
const testcase = {
|
||||
'@name': test.description,
|
||||
'@status': test.status,
|
||||
'@classname': result.request.url,
|
||||
'@time': (result.runDuration / totalTests).toFixed(3)
|
||||
};
|
||||
|
||||
if (test.status === 'fail') {
|
||||
@@ -72,7 +110,7 @@ const makeJUnitOutput = async (results, outputPath) => {
|
||||
'@name': 'Test suite has no errors',
|
||||
'@status': 'fail',
|
||||
'@classname': result.request.url,
|
||||
'@time': result.runtime.toFixed(3),
|
||||
'@time': result.runDuration.toFixed(3),
|
||||
'error': [{ '@type': 'error', '@message': result.error }]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -591,6 +591,21 @@ const runSingleRequest = async function (
|
||||
// Log pre-request test results
|
||||
logResults(preRequestTestResults, 'Pre-Request Tests');
|
||||
|
||||
// run post-response vars
|
||||
const postResponseVars = get(item, 'request.vars.res');
|
||||
if (postResponseVars?.length) {
|
||||
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
|
||||
varsRuntime.runPostResponseVars(
|
||||
postResponseVars,
|
||||
request,
|
||||
response,
|
||||
envVariables,
|
||||
runtimeVariables,
|
||||
collectionPath,
|
||||
processEnvVars
|
||||
);
|
||||
}
|
||||
|
||||
// run post response script
|
||||
const responseScriptFile = get(request, 'script.res');
|
||||
if (responseScriptFile?.length) {
|
||||
|
||||
@@ -198,6 +198,41 @@ const mergeVars = (collection, request, requestTreePath) => {
|
||||
type: 'request'
|
||||
}));
|
||||
}
|
||||
|
||||
let resVars = new Map();
|
||||
let collectionResponseVars = get(collectionRoot, 'request.vars.res', []);
|
||||
collectionResponseVars.forEach((_var) => {
|
||||
if (_var.enabled) {
|
||||
resVars.set(_var.name, _var.value);
|
||||
}
|
||||
});
|
||||
for (let i of requestTreePath) {
|
||||
if (i.type === 'folder') {
|
||||
const folderRoot = i?.draft || i?.root;
|
||||
let vars = get(folderRoot, 'request.vars.res', []);
|
||||
vars.forEach((_var) => {
|
||||
if (_var.enabled) {
|
||||
resVars.set(_var.name, _var.value);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const vars = i?.draft ? get(i, 'draft.request.vars.res', []) : get(i, 'request.vars.res', []);
|
||||
vars.forEach((_var) => {
|
||||
if (_var.enabled) {
|
||||
resVars.set(_var.name, _var.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (request?.vars) {
|
||||
request.vars.res = Array.from(resVars, ([name, value]) => ({
|
||||
name,
|
||||
value,
|
||||
enabled: true,
|
||||
type: 'response'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('makeJUnitOutput', () => {
|
||||
error: 'expected 200 to not equal 200'
|
||||
}
|
||||
],
|
||||
runtime: 1.2345678
|
||||
runDuration: 1.2345678
|
||||
},
|
||||
{
|
||||
request: {
|
||||
@@ -62,7 +62,7 @@ describe('makeJUnitOutput', () => {
|
||||
status: 'fail'
|
||||
}
|
||||
],
|
||||
runtime: 2.3456789
|
||||
runDuration: 2.3456789
|
||||
}
|
||||
];
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('makeJUnitOutput', () => {
|
||||
status: 'fail'
|
||||
}
|
||||
],
|
||||
runtime: 1.2345678,
|
||||
runDuration: 1.2345678,
|
||||
error: 'timeout of 2000ms exceeded'
|
||||
}
|
||||
];
|
||||
@@ -132,4 +132,69 @@ describe('makeJUnitOutput', () => {
|
||||
expect(failcase.error[0]['@type']).toBe('error');
|
||||
expect(failcase.error[0]['@message']).toBe('timeout of 2000ms exceeded');
|
||||
});
|
||||
|
||||
it('should include preRequestTestResults and postResponseTestResults in the junit output', () => {
|
||||
const results = [
|
||||
{
|
||||
name: 'Tests/Suite A',
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: 'https://ima.test'
|
||||
},
|
||||
preRequestTestResults: [
|
||||
{
|
||||
description: 'A test from Pre Request Script',
|
||||
status: 'pass'
|
||||
}
|
||||
],
|
||||
testResults: [
|
||||
{
|
||||
description: 'A test from Tests tab',
|
||||
status: 'pass'
|
||||
}
|
||||
],
|
||||
postResponseTestResults: [
|
||||
{
|
||||
description: 'A test from Post Response Script',
|
||||
status: 'pass'
|
||||
},
|
||||
{
|
||||
description: 'A failing test from Post Response Script',
|
||||
status: 'fail',
|
||||
error: 'expected 200 to equal 404'
|
||||
}
|
||||
],
|
||||
runDuration: 1.2345678
|
||||
}
|
||||
];
|
||||
|
||||
makeJUnitOutput(results, '/tmp/testfile.xml');
|
||||
expect(createStub).toBeCalled;
|
||||
|
||||
const junit = xmlbuilder.create.mock.calls[0][0];
|
||||
|
||||
expect(junit.testsuites).toBeDefined;
|
||||
expect(junit.testsuites.testsuite.length).toBe(1);
|
||||
expect(junit.testsuites.testsuite[0].testcase.length).toBe(4);
|
||||
expect(junit.testsuites.testsuite[0]['@tests']).toBe(4);
|
||||
|
||||
const testcase1 = junit.testsuites.testsuite[0].testcase[0];
|
||||
expect(testcase1['@name']).toBe('A test from Pre Request Script');
|
||||
expect(testcase1['@status']).toBe('pass');
|
||||
|
||||
const testcase2 = junit.testsuites.testsuite[0].testcase[1];
|
||||
expect(testcase2['@name']).toBe('A test from Tests tab');
|
||||
expect(testcase2['@status']).toBe('pass');
|
||||
|
||||
const testcase3 = junit.testsuites.testsuite[0].testcase[2];
|
||||
expect(testcase3['@name']).toBe('A test from Post Response Script');
|
||||
expect(testcase3['@status']).toBe('pass');
|
||||
|
||||
const failcase = junit.testsuites.testsuite[0].testcase[3];
|
||||
expect(failcase['@name']).toBe('A failing test from Post Response Script');
|
||||
expect(failcase['@status']).toBe('fail');
|
||||
expect(failcase.failure).toBeDefined;
|
||||
expect(failcase.failure[0]['@type']).toBe('failure');
|
||||
expect(failcase.failure[0]['@message']).toBe('expected 200 to equal 404');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { dialog, ipcMain } = require('electron');
|
||||
const Yup = require('yup');
|
||||
const { isDirectory, getCollectionStats } = require('../utils/filesystem');
|
||||
const { isDirectory, getCollectionStats, normalizeAndResolvePath } = require('../utils/filesystem');
|
||||
const { generateUidBasedOnHash } = require('../utils/common');
|
||||
const { transformBrunoConfigAfterRead } = require('../utils/transfomBrunoConfig');
|
||||
const { parseCollection } = require('@usebruno/filestore');
|
||||
@@ -132,7 +132,21 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
const openCollectionsByPathname = async (win, watcher, collectionPaths, options = {}) => {
|
||||
for (const collectionPath of collectionPaths) {
|
||||
const resolvedPath = path.isAbsolute(collectionPath)
|
||||
? collectionPath
|
||||
: normalizeAndResolvePath(collectionPath);
|
||||
if (isDirectory(resolvedPath)) {
|
||||
await openCollection(win, watcher, resolvedPath, options);
|
||||
} else {
|
||||
console.error(`Cannot open unknown folder: "${resolvedPath}"`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
openCollection,
|
||||
openCollectionDialog
|
||||
openCollectionDialog,
|
||||
openCollectionsByPathname
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ function getDefaultCollectionLocation() {
|
||||
/**
|
||||
* Import sample collection for new users
|
||||
*/
|
||||
async function importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections) {
|
||||
async function importSampleCollection(collectionLocation, mainWindow) {
|
||||
// Handle both development and production paths
|
||||
const sampleCollectionPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'data', 'sample-collection.json')
|
||||
@@ -56,7 +56,6 @@ async function importSampleCollection(collectionLocation, mainWindow, lastOpened
|
||||
collectionToImport,
|
||||
collectionLocation,
|
||||
mainWindow,
|
||||
lastOpenedCollections,
|
||||
collectionName
|
||||
);
|
||||
|
||||
@@ -80,14 +79,15 @@ async function onboardUser(mainWindow, lastOpenedCollections) {
|
||||
// Check if user already has collections (indicates they're an existing user)
|
||||
// Onboarding was added in a later version, so for existing users we should skip it
|
||||
// to avoid creating sample collections
|
||||
const collections = await lastOpenedCollections.getAll();
|
||||
// lastOpenedCollections is still used here to check for existing collections during migration
|
||||
const collections = lastOpenedCollections ? lastOpenedCollections.getAll() : [];
|
||||
if (collections.length > 0) {
|
||||
await preferencesUtil.markAsLaunched();
|
||||
return;
|
||||
}
|
||||
|
||||
const collectionLocation = getDefaultCollectionLocation();
|
||||
await importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections);
|
||||
await importSampleCollection(collectionLocation, mainWindow);
|
||||
}
|
||||
|
||||
await preferencesUtil.markAsLaunched();
|
||||
|
||||
235
packages/bruno-electron/src/app/workspace-watcher.js
Normal file
235
packages/bruno-electron/src/app/workspace-watcher.js
Normal file
@@ -0,0 +1,235 @@
|
||||
const _ = require('lodash');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const chokidar = require('chokidar');
|
||||
const yaml = require('js-yaml');
|
||||
const { generateUidBasedOnHash, uuid } = require('../utils/common');
|
||||
const { parseEnvironment } = require('@usebruno/filestore');
|
||||
const EnvironmentSecretsStore = require('../store/env-secrets');
|
||||
const { decryptStringSafe } = require('../utils/encryption');
|
||||
|
||||
const environmentSecretsStore = new EnvironmentSecretsStore();
|
||||
|
||||
/**
|
||||
* Check if environment has secret variables
|
||||
*/
|
||||
const envHasSecrets = (environment) => {
|
||||
const secrets = _.filter(environment.variables, (v) => v.secret === true);
|
||||
return secrets && secrets.length > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle workspace.yml file changes
|
||||
*/
|
||||
const handleWorkspaceFileChange = (win, workspacePath) => {
|
||||
try {
|
||||
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
|
||||
|
||||
if (!fs.existsSync(workspaceFilePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const yamlContent = fs.readFileSync(workspaceFilePath, 'utf8');
|
||||
const workspaceConfig = yaml.load(yamlContent);
|
||||
|
||||
if (workspaceConfig.type !== 'workspace') {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceUid = generateUidBasedOnHash(workspacePath);
|
||||
|
||||
win.webContents.send('main:workspace-config-updated', workspacePath, workspaceUid, workspaceConfig);
|
||||
} catch (error) {
|
||||
console.error('Error handling workspace file change:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse global environment file and handle secrets
|
||||
*/
|
||||
const parseGlobalEnvironmentFile = async (pathname, workspacePath, workspaceUid) => {
|
||||
const basename = path.basename(pathname);
|
||||
const environmentName = basename.slice(0, -'.yml'.length);
|
||||
|
||||
const file = {
|
||||
meta: {
|
||||
workspaceUid,
|
||||
pathname,
|
||||
name: basename
|
||||
}
|
||||
};
|
||||
|
||||
const content = fs.readFileSync(pathname, 'utf8');
|
||||
file.data = await parseEnvironment(content, { format: 'yml' });
|
||||
file.data.name = environmentName;
|
||||
file.data.uid = generateUidBasedOnHash(pathname);
|
||||
|
||||
// Ensure all variables have UIDs
|
||||
_.each(_.get(file, 'data.variables', []), (variable) => {
|
||||
if (!variable.uid) {
|
||||
variable.uid = uuid();
|
||||
}
|
||||
});
|
||||
|
||||
// Decrypt secrets if present
|
||||
if (envHasSecrets(file.data)) {
|
||||
const envSecrets = environmentSecretsStore.getEnvSecrets(workspacePath, file.data);
|
||||
_.each(envSecrets, (secret) => {
|
||||
const variable = _.find(file.data.variables, (v) => v.name === secret.name);
|
||||
if (variable && secret.value) {
|
||||
const decryptionResult = decryptStringSafe(secret.value);
|
||||
variable.value = decryptionResult.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return file;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle global environment file add
|
||||
*/
|
||||
const handleGlobalEnvironmentFileAdd = async (win, pathname, workspacePath, workspaceUid) => {
|
||||
try {
|
||||
const file = await parseGlobalEnvironmentFile(pathname, workspacePath, workspaceUid);
|
||||
win.webContents.send('main:global-environment-added', workspaceUid, file);
|
||||
} catch (error) {
|
||||
console.error('Error handling global environment file add:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle global environment file change
|
||||
*/
|
||||
const handleGlobalEnvironmentFileChange = async (win, pathname, workspacePath, workspaceUid) => {
|
||||
try {
|
||||
const file = await parseGlobalEnvironmentFile(pathname, workspacePath, workspaceUid);
|
||||
win.webContents.send('main:global-environment-changed', workspaceUid, file);
|
||||
} catch (error) {
|
||||
console.error('Error handling global environment file change:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle global environment file unlink
|
||||
*/
|
||||
const handleGlobalEnvironmentFileUnlink = async (win, pathname, workspaceUid) => {
|
||||
try {
|
||||
const environmentUid = generateUidBasedOnHash(pathname);
|
||||
win.webContents.send('main:global-environment-deleted', workspaceUid, environmentUid);
|
||||
} catch (error) {
|
||||
console.error('Error handling global environment file unlink:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Workspace Watcher
|
||||
* Watches workspace files for changes and notifies the renderer
|
||||
*/
|
||||
class WorkspaceWatcher {
|
||||
constructor() {
|
||||
this.watchers = {};
|
||||
this.environmentWatchers = {};
|
||||
}
|
||||
|
||||
addWatcher(win, workspacePath) {
|
||||
const workspaceFilePath = path.join(workspacePath, 'workspace.yml');
|
||||
const environmentsDir = path.join(workspacePath, 'environments');
|
||||
const workspaceUid = generateUidBasedOnHash(workspacePath);
|
||||
|
||||
// Close existing watchers if any
|
||||
if (this.watchers[workspacePath]) {
|
||||
this.watchers[workspacePath].close();
|
||||
}
|
||||
if (this.environmentWatchers[workspacePath]) {
|
||||
this.environmentWatchers[workspacePath].close();
|
||||
}
|
||||
|
||||
const self = this;
|
||||
setTimeout(() => {
|
||||
// Guard against window being destroyed during delay
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch workspace.yml file
|
||||
const watcher = chokidar.watch(workspaceFilePath, {
|
||||
ignoreInitial: false,
|
||||
persistent: true,
|
||||
ignorePermissionErrors: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 80,
|
||||
pollInterval: 10
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on('change', () => handleWorkspaceFileChange(win, workspacePath));
|
||||
watcher.on('add', () => handleWorkspaceFileChange(win, workspacePath));
|
||||
|
||||
self.watchers[workspacePath] = watcher;
|
||||
|
||||
// Watch global environment files (.yml)
|
||||
if (fs.existsSync(environmentsDir)) {
|
||||
const envWatcher = chokidar.watch(path.join(environmentsDir, `*.yml`), {
|
||||
ignoreInitial: true,
|
||||
persistent: true,
|
||||
ignorePermissionErrors: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 100,
|
||||
pollInterval: 10
|
||||
}
|
||||
});
|
||||
|
||||
envWatcher.on('add', (pathname) => {
|
||||
handleGlobalEnvironmentFileAdd(win, pathname, workspacePath, workspaceUid);
|
||||
});
|
||||
|
||||
envWatcher.on('change', (pathname) => {
|
||||
handleGlobalEnvironmentFileChange(win, pathname, workspacePath, workspaceUid);
|
||||
});
|
||||
|
||||
envWatcher.on('unlink', (pathname) => {
|
||||
handleGlobalEnvironmentFileUnlink(win, pathname, workspaceUid);
|
||||
});
|
||||
|
||||
self.environmentWatchers[workspacePath] = envWatcher;
|
||||
} else {
|
||||
// Watch for environments directory creation
|
||||
const dirWatcher = chokidar.watch(environmentsDir, {
|
||||
ignoreInitial: false,
|
||||
persistent: true,
|
||||
ignorePermissionErrors: true,
|
||||
depth: 0
|
||||
});
|
||||
|
||||
dirWatcher.on('addDir', () => {
|
||||
dirWatcher.close();
|
||||
self.addWatcher(win, workspacePath);
|
||||
});
|
||||
|
||||
self.environmentWatchers[workspacePath] = dirWatcher;
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
removeWatcher(workspacePath) {
|
||||
try {
|
||||
if (this.watchers[workspacePath]) {
|
||||
this.watchers[workspacePath].close();
|
||||
delete this.watchers[workspacePath];
|
||||
}
|
||||
if (this.environmentWatchers[workspacePath]) {
|
||||
this.environmentWatchers[workspacePath].close();
|
||||
delete this.environmentWatchers[workspacePath];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing workspace watcher:', error);
|
||||
}
|
||||
}
|
||||
|
||||
hasWatcher(workspacePath) {
|
||||
return Boolean(this.watchers[workspacePath]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WorkspaceWatcher;
|
||||
@@ -38,8 +38,11 @@ const registerCollectionsIpc = require('./ipc/collection');
|
||||
const registerFilesystemIpc = require('./ipc/filesystem');
|
||||
const registerPreferencesIpc = require('./ipc/preferences');
|
||||
const registerSystemMonitorIpc = require('./ipc/system-monitor');
|
||||
const registerWorkspaceIpc = require('./ipc/workspace');
|
||||
const collectionWatcher = require('./app/collection-watcher');
|
||||
const WorkspaceWatcher = require('./app/workspace-watcher');
|
||||
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
|
||||
const { globalEnvironmentsManager } = require('./store/workspace-environments');
|
||||
const registerNotificationsIpc = require('./ipc/notifications');
|
||||
const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
|
||||
const TerminalManager = require('./ipc/terminal');
|
||||
@@ -54,13 +57,15 @@ const lastOpenedCollections = new LastOpenedCollections();
|
||||
const systemMonitor = new SystemMonitor();
|
||||
const terminalManager = new TerminalManager();
|
||||
|
||||
const workspaceWatcher = new WorkspaceWatcher();
|
||||
|
||||
// Reference: https://content-security-policy.com/
|
||||
const contentSecurityPolicy = [
|
||||
'default-src \'self\'',
|
||||
'connect-src \'self\' https://*.posthog.com',
|
||||
'font-src \'self\' https: data:;',
|
||||
'frame-src data:',
|
||||
'script-src \'self\' \'unsafe-inline\' data:',
|
||||
'script-src \'self\' data:',
|
||||
// this has been commented out to make oauth2 work
|
||||
// "form-action 'none'",
|
||||
// we make an exception and allow http for images so that
|
||||
@@ -73,6 +78,8 @@ const contentSecurityPolicy = [
|
||||
setContentSecurityPolicy(contentSecurityPolicy.join(';') + ';');
|
||||
|
||||
const menu = Menu.buildFromTemplate(menuTemplate);
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
let mainWindow;
|
||||
|
||||
@@ -113,7 +120,11 @@ app.on('ready', async () => {
|
||||
webviewTag: true
|
||||
},
|
||||
title: 'Bruno',
|
||||
icon: path.join(__dirname, 'about/256x256.png')
|
||||
icon: path.join(__dirname, 'about/256x256.png'),
|
||||
// Custom title bar – ensure React titlebar occupies the window chrome on all OSes
|
||||
titleBarStyle: isMac ? 'hiddenInset' : isWindows ? 'hidden' : 'default',
|
||||
titleBarOverlay: isWindows ? { height: 36 } : undefined,
|
||||
trafficLightPosition: isMac ? { x: 12, y: 10 } : undefined
|
||||
// we will bring this back
|
||||
// see https://github.com/usebruno/bruno/issues/440
|
||||
// autoHideMenuBar: true
|
||||
@@ -161,6 +172,15 @@ app.on('ready', async () => {
|
||||
|
||||
mainWindow.on('maximize', () => saveMaximized(true));
|
||||
mainWindow.on('unmaximize', () => saveMaximized(false));
|
||||
|
||||
// Full screen events for title bar padding adjustment
|
||||
mainWindow.on('enter-full-screen', () => {
|
||||
mainWindow.webContents.send('main:enter-full-screen');
|
||||
});
|
||||
mainWindow.on('leave-full-screen', () => {
|
||||
mainWindow.webContents.send('main:leave-full-screen');
|
||||
});
|
||||
|
||||
mainWindow.on('close', (e) => {
|
||||
e.preventDefault();
|
||||
terminalManager.cleanup(mainWindow.webContents);
|
||||
@@ -214,9 +234,10 @@ app.on('ready', async () => {
|
||||
|
||||
// register all ipc handlers
|
||||
registerNetworkIpc(mainWindow);
|
||||
registerGlobalEnvironmentsIpc(mainWindow);
|
||||
registerCollectionsIpc(mainWindow, collectionWatcher, lastOpenedCollections);
|
||||
registerPreferencesIpc(mainWindow, collectionWatcher, lastOpenedCollections);
|
||||
registerGlobalEnvironmentsIpc(mainWindow, globalEnvironmentsManager);
|
||||
registerCollectionsIpc(mainWindow, collectionWatcher);
|
||||
registerPreferencesIpc(mainWindow, collectionWatcher);
|
||||
registerWorkspaceIpc(mainWindow, workspaceWatcher);
|
||||
registerNotificationsIpc(mainWindow, collectionWatcher);
|
||||
registerFilesystemIpc(mainWindow);
|
||||
registerSystemMonitorIpc(mainWindow, systemMonitor);
|
||||
|
||||
@@ -30,17 +30,11 @@ const {
|
||||
sanitizeName,
|
||||
isWSLPath,
|
||||
safeToRename,
|
||||
getSubDirectories,
|
||||
isWindowsOS,
|
||||
readDir,
|
||||
hasRequestExtension,
|
||||
getCollectionFormat,
|
||||
searchForRequestFiles,
|
||||
normalizeAndResolvePath,
|
||||
validateName,
|
||||
chooseFileToSave,
|
||||
exists,
|
||||
isFile,
|
||||
getCollectionStats,
|
||||
sizeInMB,
|
||||
safeWriteFileSync,
|
||||
@@ -49,7 +43,7 @@ const {
|
||||
getPaths,
|
||||
generateUniqueName
|
||||
} = require('../utils/filesystem');
|
||||
const { openCollectionDialog } = require('../app/collections');
|
||||
const { openCollectionDialog, openCollectionsByPathname } = require('../app/collections');
|
||||
const { generateUidBasedOnHash, stringifyJson, safeStringifyJSON, safeParseJSON } = require('../utils/common');
|
||||
const { moveRequestUid, deleteRequestUid } = require('../cache/requestUids');
|
||||
const { deleteCookiesForDomain, getDomainsWithCookies, addCookieForDomain, modifyCookieForDomain, parseCookieString, createCookieString, deleteCookie } = require('../utils/cookies');
|
||||
@@ -80,12 +74,8 @@ const envHasSecrets = (environment = {}) => {
|
||||
return secrets && secrets.length > 0;
|
||||
};
|
||||
|
||||
const findCollectionPathByItemPath = (filePath, lastOpenedCollections) => {
|
||||
const openCollectionPaths = collectionWatcher.getAllWatcherPaths();
|
||||
const lastOpenedPaths = lastOpenedCollections ? lastOpenedCollections.getAll() : [];
|
||||
|
||||
// Combine both currently watched collections and last opened collections
|
||||
const allCollectionPaths = [...new Set([...openCollectionPaths, ...lastOpenedPaths])];
|
||||
const findCollectionPathByItemPath = (filePath) => {
|
||||
const allCollectionPaths = collectionWatcher.getAllWatcherPaths();
|
||||
|
||||
// Find the collection path that contains this file
|
||||
// Sort by length descending to find the most specific (deepest) match first
|
||||
@@ -100,20 +90,21 @@ const findCollectionPathByItemPath = (filePath, lastOpenedCollections) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const validatePathIsInsideCollection = (filePath, lastOpenedCollections) => {
|
||||
const collectionPath = findCollectionPathByItemPath(filePath, lastOpenedCollections);
|
||||
const validatePathIsInsideCollection = (filePath) => {
|
||||
const collectionPath = findCollectionPathByItemPath(filePath);
|
||||
|
||||
if (!collectionPath) {
|
||||
throw new Error(`Path: ${filePath} should be inside a collection`);
|
||||
}
|
||||
};
|
||||
|
||||
const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
const registerRendererEventHandlers = (mainWindow, watcher) => {
|
||||
// create collection
|
||||
ipcMain.handle(
|
||||
'renderer:create-collection',
|
||||
async (event, collectionName, collectionFolderName, collectionLocation, format = 'bru') => {
|
||||
async (event, collectionName, collectionFolderName, collectionLocation, options = {}) => {
|
||||
try {
|
||||
const format = options.format || 'bru';
|
||||
collectionFolderName = sanitizeName(collectionFolderName);
|
||||
const dirPath = path.join(collectionLocation, collectionFolderName);
|
||||
if (fs.existsSync(dirPath)) {
|
||||
@@ -194,11 +185,12 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
let brunoConfig;
|
||||
|
||||
if (format === 'yml') {
|
||||
const content = fs.readFileSync('opencollection.yml', 'utf8');
|
||||
const configFilePath = path.join(previousPath, 'opencollection.yml');
|
||||
const content = fs.readFileSync(configFilePath, 'utf8');
|
||||
const {
|
||||
brunoConfig: parsedBrunoConfig,
|
||||
collectionRoot
|
||||
} = parseCollection(content);
|
||||
} = parseCollection(content, { format });
|
||||
|
||||
brunoConfig = parsedBrunoConfig;
|
||||
brunoConfig.name = collectionName;
|
||||
@@ -206,7 +198,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
const newContent = stringifyCollection(collectionRoot, brunoConfig, { format });
|
||||
await writeFile(path.join(dirPath, 'opencollection.yml'), newContent);
|
||||
} else if (format === 'bru') {
|
||||
const content = fs.readFileSync('bruno.json', 'utf8');
|
||||
const configFilePath = path.join(previousPath, 'bruno.json');
|
||||
const content = fs.readFileSync(configFilePath, 'utf8');
|
||||
brunoConfig = JSON.parse(content);
|
||||
brunoConfig.name = collectionName;
|
||||
const newContent = await stringifyJson(brunoConfig);
|
||||
@@ -222,8 +215,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
const relativePath = path.relative(previousPath, sourceFilePath);
|
||||
const newFilePath = path.join(dirPath, relativePath);
|
||||
|
||||
// skip if the file is opencollection.yml at the root of the collection
|
||||
if (path.basename(sourceFilePath) === 'opencollection.yml' && path.dirname(sourceFilePath) === previousPath) {
|
||||
// skip if the file is opencollection.yml or bruno.json at the root of the collection
|
||||
const isRootConfigFile = (path.basename(sourceFilePath) === 'opencollection.yml' || path.basename(sourceFilePath) === 'bruno.json')
|
||||
&& path.dirname(sourceFilePath) === previousPath;
|
||||
|
||||
if (isRootConfigFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -247,18 +243,20 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
const format = getCollectionFormat(collectionPathname);
|
||||
|
||||
if (format === 'yml') {
|
||||
const content = fs.readFileSync('opencollection.yml', 'utf8');
|
||||
const configFilePath = path.join(collectionPathname, 'opencollection.yml');
|
||||
const content = fs.readFileSync(configFilePath, 'utf8');
|
||||
const {
|
||||
brunoConfig,
|
||||
collectionRoot
|
||||
} = parseCollection(content);
|
||||
} = parseCollection(content, { format: 'yml' });
|
||||
|
||||
brunoConfig.name = newName;
|
||||
|
||||
const newContent = stringifyCollection(collectionRoot, brunoConfig, { format });
|
||||
const newContent = stringifyCollection(collectionRoot, brunoConfig, { format: 'yml' });
|
||||
await writeFile(path.join(collectionPathname, 'opencollection.yml'), newContent);
|
||||
} else if (format === 'bru') {
|
||||
const content = fs.readFileSync('bruno.json', 'utf8');
|
||||
const configFilePath = path.join(collectionPathname, 'bruno.json');
|
||||
const content = fs.readFileSync(configFilePath, 'utf8');
|
||||
const brunoConfig = JSON.parse(content);
|
||||
brunoConfig.name = newName;
|
||||
const newContent = await stringifyJson(brunoConfig);
|
||||
@@ -317,7 +315,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
throw new Error(`path: ${pathname} already exists`);
|
||||
}
|
||||
|
||||
const collectionPath = findCollectionPathByItemPath(pathname, lastOpenedCollections);
|
||||
const collectionPath = findCollectionPathByItemPath(pathname);
|
||||
if (!collectionPath) {
|
||||
throw new Error('Collection not found for the given pathname');
|
||||
}
|
||||
@@ -328,7 +326,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
if (!validateName(baseFilename)) {
|
||||
throw new Error(`${request.filename} is not a valid filename`);
|
||||
}
|
||||
validatePathIsInsideCollection(pathname, lastOpenedCollections);
|
||||
validatePathIsInsideCollection(pathname);
|
||||
|
||||
const content = await stringifyRequestViaWorker(request, { format });
|
||||
await writeFile(pathname, content);
|
||||
@@ -806,31 +804,38 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:open-collection', () => {
|
||||
ipcMain.handle('renderer:open-collection', async () => {
|
||||
if (watcher && mainWindow) {
|
||||
openCollectionDialog(mainWindow, watcher);
|
||||
await openCollectionDialog(mainWindow, watcher);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:remove-collection', async (event, collectionPath, collectionUid) => {
|
||||
ipcMain.handle('renderer:open-multiple-collections', async (e, collectionPaths) => {
|
||||
if (watcher && mainWindow) {
|
||||
console.log(`watcher stopWatching: ${collectionPath}`);
|
||||
watcher.removeWatcher(collectionPath, mainWindow, collectionUid);
|
||||
lastOpenedCollections.remove(collectionPath);
|
||||
await openCollectionsByPathname(mainWindow, watcher, collectionPaths);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:remove-collection', async (event, collectionPath, collectionUid, workspacePath) => {
|
||||
if (watcher && mainWindow) {
|
||||
watcher.removeWatcher(collectionPath, mainWindow, collectionUid);
|
||||
|
||||
// If wsclient was initialised for any collections that are opened
|
||||
// then close for the current collection
|
||||
if (wsClient) {
|
||||
wsClient.closeForCollection(collectionUid);
|
||||
}
|
||||
}
|
||||
|
||||
if (workspacePath && workspacePath !== 'default') {
|
||||
try {
|
||||
const { removeCollectionFromWorkspace } = require('../utils/workspace-config');
|
||||
await removeCollectionFromWorkspace(workspacePath, collectionPath);
|
||||
} catch (error) {
|
||||
console.error('Error removing collection from workspace.yml:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:update-collection-paths', async (_, collectionPaths) => {
|
||||
lastOpenedCollections.update(collectionPaths);
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:import-collection', async (event, collection, collectionLocation, format = 'bru') => {
|
||||
ipcMain.handle('renderer:import-collection', async (_, collection, collectionLocation, format = 'bru') => {
|
||||
try {
|
||||
let collectionName = sanitizeName(collection.name);
|
||||
let collectionPath = path.join(collectionLocation, collectionName);
|
||||
@@ -840,8 +845,8 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
}
|
||||
|
||||
// Recursive function to parse the collection items and create files/folders
|
||||
const parseCollectionItems = (items = [], currentPath) => {
|
||||
items.forEach(async (item) => {
|
||||
const parseCollectionItems = async (items = [], currentPath) => {
|
||||
await Promise.all(items.map(async (item) => {
|
||||
if (['http-request', 'graphql-request', 'grpc-request', 'ws-request'].includes(item.type)) {
|
||||
let sanitizedFilename = sanitizeName(item?.filename || `${item.name}.${format}`);
|
||||
const content = await stringifyRequestViaWorker(item, { format });
|
||||
@@ -861,7 +866,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
}
|
||||
|
||||
if (item.items && item.items.length) {
|
||||
parseCollectionItems(item.items, folderPath);
|
||||
await parseCollectionItems(item.items, folderPath);
|
||||
}
|
||||
}
|
||||
// Handle items of type 'js'
|
||||
@@ -870,21 +875,21 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
const filePath = path.join(currentPath, sanitizedFilename);
|
||||
safeWriteFileSync(filePath, item.fileContent);
|
||||
}
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
const parseEnvironments = (environments = [], collectionPath) => {
|
||||
const parseEnvironments = async (environments = [], collectionPath) => {
|
||||
const envDirPath = path.join(collectionPath, 'environments');
|
||||
if (!fs.existsSync(envDirPath)) {
|
||||
fs.mkdirSync(envDirPath);
|
||||
}
|
||||
|
||||
environments.forEach(async (env) => {
|
||||
await Promise.all(environments.map(async (env) => {
|
||||
const content = await stringifyEnvironment(env, { format });
|
||||
let sanitizedEnvFilename = sanitizeName(`${env.name}.${format}`);
|
||||
const filePath = path.join(envDirPath, sanitizedEnvFilename);
|
||||
safeWriteFileSync(filePath, content);
|
||||
});
|
||||
}));
|
||||
};
|
||||
|
||||
const getBrunoJsonConfig = (collection) => {
|
||||
@@ -927,11 +932,11 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
|
||||
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
|
||||
|
||||
lastOpenedCollections.add(collectionPath);
|
||||
|
||||
// create folder and files based on collection
|
||||
await parseCollectionItems(collection.items, collectionPath);
|
||||
await parseEnvironments(collection.environments, collectionPath);
|
||||
|
||||
return collectionPath;
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
@@ -1537,7 +1542,7 @@ const registerRendererEventHandlers = (mainWindow, watcher, lastOpenedCollection
|
||||
});
|
||||
};
|
||||
|
||||
const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
const registerMainEventHandlers = (mainWindow, watcher) => {
|
||||
ipcMain.on('main:open-collection', () => {
|
||||
if (watcher && mainWindow) {
|
||||
openCollectionDialog(mainWindow, watcher);
|
||||
@@ -1550,7 +1555,6 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) =
|
||||
});
|
||||
|
||||
ipcMain.on('main:collection-opened', async (win, pathname, uid, brunoConfig) => {
|
||||
lastOpenedCollections.add(pathname);
|
||||
app.addRecentDocument(pathname);
|
||||
});
|
||||
|
||||
@@ -1568,9 +1572,9 @@ const registerMainEventHandlers = (mainWindow, watcher, lastOpenedCollections) =
|
||||
});
|
||||
};
|
||||
|
||||
const registerCollectionsIpc = (mainWindow, watcher, lastOpenedCollections) => {
|
||||
registerRendererEventHandlers(mainWindow, watcher, lastOpenedCollections);
|
||||
registerMainEventHandlers(mainWindow, watcher, lastOpenedCollections);
|
||||
const registerCollectionsIpc = (mainWindow, watcher) => {
|
||||
registerRendererEventHandlers(mainWindow, watcher);
|
||||
registerMainEventHandlers(mainWindow, watcher);
|
||||
};
|
||||
|
||||
module.exports = registerCollectionsIpc;
|
||||
|
||||
@@ -3,56 +3,99 @@ const { ipcMain } = require('electron');
|
||||
const { globalEnvironmentsStore } = require('../store/global-environments');
|
||||
const { generateUniqueName, sanitizeName } = require('../utils/filesystem');
|
||||
|
||||
const registerGlobalEnvironmentsIpc = (mainWindow) => {
|
||||
// GLOBAL ENVIRONMENTS
|
||||
|
||||
ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables }) => {
|
||||
const registerGlobalEnvironmentsIpc = (mainWindow, workspaceEnvironmentsManager) => {
|
||||
ipcMain.handle('renderer:create-global-environment', async (event, { uid, name, variables, workspaceUid, workspacePath }) => {
|
||||
try {
|
||||
// Get existing global environment names to generate unique name
|
||||
// If workspace path provided, use workspace environments manager
|
||||
if (workspacePath && workspaceEnvironmentsManager) {
|
||||
const { globalEnvironments } = await workspaceEnvironmentsManager.getGlobalEnvironmentsByPath(workspacePath);
|
||||
const existingNames = globalEnvironments?.map((env) => env.name) || [];
|
||||
|
||||
const sanitizedName = sanitizeName(name);
|
||||
const uniqueName = generateUniqueName(sanitizedName, (name) => existingNames.includes(name));
|
||||
|
||||
return await workspaceEnvironmentsManager.addGlobalEnvironmentByPath(workspacePath, { uid, name: uniqueName, variables });
|
||||
}
|
||||
|
||||
const existingGlobalEnvironments = globalEnvironmentsStore.getGlobalEnvironments();
|
||||
const existingNames = existingGlobalEnvironments?.map((env) => env.name) || [];
|
||||
|
||||
// Generate unique name based on existing global environment names
|
||||
const sanitizedName = sanitizeName(name);
|
||||
const uniqueName = generateUniqueName(sanitizedName, (name) => existingNames.includes(name));
|
||||
|
||||
globalEnvironmentsStore.addGlobalEnvironment({ uid, name: uniqueName, variables });
|
||||
|
||||
// Return the unique name that was actually used
|
||||
return { name: uniqueName };
|
||||
} catch (error) {
|
||||
console.error('Error in renderer:create-global-environment:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:save-global-environment', async (event, { environmentUid, variables }) => {
|
||||
ipcMain.handle('renderer:save-global-environment', async (event, { environmentUid, variables, workspaceUid, workspacePath }) => {
|
||||
try {
|
||||
if (workspacePath && workspaceEnvironmentsManager) {
|
||||
return await workspaceEnvironmentsManager.saveGlobalEnvironmentByPath(workspacePath, { environmentUid, variables });
|
||||
}
|
||||
|
||||
globalEnvironmentsStore.saveGlobalEnvironment({ environmentUid, variables });
|
||||
} catch (error) {
|
||||
console.error('Error in renderer:save-global-environment:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:rename-global-environment', async (event, { environmentUid, name }) => {
|
||||
ipcMain.handle('renderer:rename-global-environment', async (event, { environmentUid, name, workspaceUid, workspacePath }) => {
|
||||
try {
|
||||
if (workspacePath && workspaceEnvironmentsManager) {
|
||||
return await workspaceEnvironmentsManager.renameGlobalEnvironmentByPath(workspacePath, { environmentUid, name });
|
||||
}
|
||||
|
||||
globalEnvironmentsStore.renameGlobalEnvironment({ environmentUid, name });
|
||||
} catch (error) {
|
||||
console.error('Error in renderer:rename-global-environment:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:delete-global-environment', async (event, { environmentUid }) => {
|
||||
ipcMain.handle('renderer:delete-global-environment', async (event, { environmentUid, workspaceUid, workspacePath }) => {
|
||||
try {
|
||||
if (workspacePath && workspaceEnvironmentsManager) {
|
||||
return await workspaceEnvironmentsManager.deleteGlobalEnvironmentByPath(workspacePath, { environmentUid });
|
||||
}
|
||||
|
||||
globalEnvironmentsStore.deleteGlobalEnvironment({ environmentUid });
|
||||
} catch (error) {
|
||||
console.error('Error in renderer:delete-global-environment:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:select-global-environment', async (event, { environmentUid }) => {
|
||||
ipcMain.handle('renderer:select-global-environment', async (event, { environmentUid, workspaceUid, workspacePath }) => {
|
||||
try {
|
||||
if (workspacePath && workspaceEnvironmentsManager) {
|
||||
return await workspaceEnvironmentsManager.selectGlobalEnvironmentByPath(workspacePath, { environmentUid });
|
||||
}
|
||||
|
||||
globalEnvironmentsStore.selectGlobalEnvironment({ environmentUid });
|
||||
} catch (error) {
|
||||
console.error('Error in renderer:select-global-environment:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('renderer:get-global-environments', async (event, { workspaceUid, workspacePath }) => {
|
||||
try {
|
||||
if (workspacePath && workspaceEnvironmentsManager) {
|
||||
return await workspaceEnvironmentsManager.getGlobalEnvironmentsByPath(workspacePath);
|
||||
}
|
||||
|
||||
return {
|
||||
globalEnvironments: globalEnvironmentsStore.getGlobalEnvironments() || [],
|
||||
activeGlobalEnvironmentUid: globalEnvironmentsStore.getActiveGlobalEnvironmentUid()
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in renderer:get-global-environments:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -42,10 +42,17 @@ const getCertsAndProxyConfig = async ({
|
||||
httpsAgentRequestFields['ca'] = caCertificates || [];
|
||||
|
||||
const { promptVariables } = collection;
|
||||
const collectionVariables = request.collectionVariables || {};
|
||||
const folderVariables = request.folderVariables || {};
|
||||
const requestVariables = request.requestVariables || {};
|
||||
|
||||
const brunoConfig = getBrunoConfig(collectionUid, collection);
|
||||
const interpolationOptions = {
|
||||
globalEnvironmentVariables,
|
||||
collectionVariables,
|
||||
envVars,
|
||||
folderVariables,
|
||||
requestVariables,
|
||||
runtimeVariables,
|
||||
promptVariables,
|
||||
processEnvVars
|
||||
|
||||
@@ -34,14 +34,18 @@ const registerGrpcEventHandlers = (window) => {
|
||||
ipcMain.handle('grpc:start-connection', async (event, { request, collection, environment, runtimeVariables }) => {
|
||||
try {
|
||||
const requestCopy = cloneDeep(request);
|
||||
|
||||
const preparedRequest = await prepareGrpcRequest(requestCopy, collection, environment, runtimeVariables, {});
|
||||
|
||||
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
|
||||
if (!protocolRegex.test(preparedRequest.url)) {
|
||||
preparedRequest.url = `http://${preparedRequest.url}`;
|
||||
}
|
||||
|
||||
// Get certificates and proxy configuration
|
||||
const certsAndProxyConfig = await getCertsAndProxyConfig({
|
||||
collectionUid: collection.uid,
|
||||
collection,
|
||||
request: requestCopy.request,
|
||||
request: preparedRequest,
|
||||
envVars: preparedRequest.envVars,
|
||||
runtimeVariables,
|
||||
processEnvVars: preparedRequest.processEnvVars,
|
||||
@@ -169,11 +173,16 @@ const registerGrpcEventHandlers = (window) => {
|
||||
const requestCopy = cloneDeep(request);
|
||||
const preparedRequest = await prepareGrpcRequest(requestCopy, collection, environment, runtimeVariables);
|
||||
|
||||
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
|
||||
if (!protocolRegex.test(preparedRequest.url)) {
|
||||
preparedRequest.url = `http://${preparedRequest.url}`;
|
||||
}
|
||||
|
||||
// Get certificates and proxy configuration
|
||||
const certsAndProxyConfig = await getCertsAndProxyConfig({
|
||||
collectionUid: collection.uid,
|
||||
collection,
|
||||
request: requestCopy.request,
|
||||
request: preparedRequest,
|
||||
envVars: preparedRequest.envVars,
|
||||
runtimeVariables,
|
||||
processEnvVars: preparedRequest.processEnvVars,
|
||||
@@ -274,6 +283,12 @@ const registerGrpcEventHandlers = (window) => {
|
||||
try {
|
||||
const requestCopy = cloneDeep(request);
|
||||
const preparedRequest = await prepareGrpcRequest(requestCopy, collection, environment, runtimeVariables, {});
|
||||
|
||||
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
|
||||
if (!protocolRegex.test(preparedRequest.url)) {
|
||||
preparedRequest.url = `http://${preparedRequest.url}`;
|
||||
}
|
||||
|
||||
const interpolationOptions = {
|
||||
envVars: preparedRequest.envVars,
|
||||
runtimeVariables,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user