add: manage workspace (#6424)

* add: manage workspace

* fixes

* replace dropdown to MenuDropdown

* rm: refs
This commit is contained in:
naman-bruno
2025-12-17 20:35:18 +05:30
committed by GitHub
parent 4c1fba611a
commit 73124fd715
9 changed files with 517 additions and 4 deletions

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconPin, IconPinned, IconPlus, IconUpload } from '@tabler/icons';
import { IconCheck, IconChevronDown, IconFolder, IconHome, IconPin, IconPinned, IconPlus, IconUpload, IconSettings } from '@tabler/icons';
import { forwardRef, useCallback, useEffect, useMemo, 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 { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
@@ -90,6 +90,10 @@ const AppTitleBar = () => {
setCreateWorkspaceModalOpen(true);
};
const handleManageWorkspaces = () => {
dispatch(showManageWorkspacePage());
};
const handleImportWorkspace = () => {
setImportWorkspaceModalOpen(true);
};
@@ -166,6 +170,12 @@ const AppTitleBar = () => {
leftSection: IconUpload,
label: 'Import workspace',
onClick: handleImportWorkspace
},
{
id: 'manage-workspaces',
leftSection: IconSettings,
label: 'Manage workspaces',
onClick: handleManageWorkspaces
}
);

View File

@@ -0,0 +1,55 @@
import React, { useState } from 'react';
import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { IconFolder } from '@tabler/icons';
import { closeWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
const DeleteWorkspace = ({ onClose, workspace }) => {
const dispatch = useDispatch();
const [isDeleting, setIsDeleting] = useState(false);
const onConfirm = async () => {
if (isDeleting) return;
try {
setIsDeleting(true);
await dispatch(closeWorkspaceAction(workspace.uid));
onClose();
} catch (error) {
toast.error(error?.message || 'An error occurred while removing the workspace');
setIsDeleting(false);
}
};
return (
<Portal>
<Modal
size="sm"
title="Remove Workspace"
confirmText={isDeleting ? 'Removing...' : 'Remove'}
handleConfirm={onConfirm}
handleCancel={onClose}
confirmDisabled={isDeleting}
confirmButtonClass="btn-danger"
>
<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 remove workspace <span className="font-semibold">{workspace?.name}</span>?
</div>
<div className="mt-4">
The workspace will still be available in the file system and can be re-opened later.
</div>
</Modal>
</Portal>
);
};
export default DeleteWorkspace;

View File

@@ -0,0 +1,95 @@
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, useSelector } from 'react-redux';
import { renameWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
const RenameWorkspace = ({ onClose, workspace }) => {
const dispatch = useDispatch();
const { workspaces } = useSelector((state) => state.workspaces);
const inputRef = useRef();
const formik = useFormik({
enableReinitialize: true,
initialValues: {
name: workspace.name
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
.max(255, 'must be 255 characters or less')
.required('name is required')
.test('unique-name', 'A workspace with this name already exists', function (value) {
if (!value) return true;
return !workspaces.some((w) =>
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
);
})
}),
onSubmit: (values) => {
if (values.name === workspace.name) {
onClose();
return;
}
dispatch(renameWorkspaceAction(workspace.uid, values.name))
.then(() => {
onClose();
})
.catch((error) => {
toast.error(error?.message || 'An error occurred while renaming the workspace');
});
}
});
useEffect(() => {
if (inputRef && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [inputRef]);
const onSubmit = () => {
formik.handleSubmit();
};
return (
<Portal>
<Modal
size="sm"
title="Rename Workspace"
confirmText="Rename"
handleConfirm={onSubmit}
handleCancel={onClose}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="workspace-name" className="block font-semibold">
Workspace Name
</label>
<input
id="workspace-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 RenameWorkspace;

View File

@@ -0,0 +1,175 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
.manage-workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.back-button {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
cursor: pointer;
color: ${(props) => props.theme.text};
}
.header-title {
font-size: 15px;
font-weight: 600;
color: ${(props) => props.theme.text};
}
.create-workspace-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: ${(props) => props.theme.border.radius.base};
background: ${(props) => props.theme.workspace.accent};
color: white;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
cursor: pointer;
border: none;
}
.workspace-list {
flex: 1;
overflow-y: auto;
padding: 0 16px;
}
.workspace-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
}
.workspace-info {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.workspace-name-row {
display: flex;
align-items: center;
gap: 6px;
}
.workspace-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.default {
color: ${(props) => props.theme.colors.text.muted};
}
&.regular {
color: ${(props) => props.theme.workspace.accent};
}
}
.workspace-name {
font-size: ${(props) => props.theme.font.size.md};
font-weight: 500;
color: ${(props) => props.theme.text};
}
.default-badge {
padding: 1px 6px;
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.sidebar.badge.bg};
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
}
.workspace-path {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: transparent;
border: none;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.xs};
cursor: pointer;
}
.more-actions-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
background: transparent;
border: none;
color: ${(props) => props.theme.text};
cursor: pointer;
}
.dropdown-menu {
min-width: 120px;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.sm};
&.danger {
color: ${(props) => props.theme.colors.text.danger};
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,162 @@
import React, { useState, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconArrowLeft, IconPlus, IconFolder, IconLock, IconDots, IconCategory, IconLogin } from '@tabler/icons';
import toast from 'react-hot-toast';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { sortWorkspaces } from 'utils/workspaces';
import CreateWorkspace from 'components/WorkspaceSidebar/CreateWorkspace';
import RenameWorkspace from './RenameWorkspace';
import DeleteWorkspace from './DeleteWorkspace';
import StyledWrapper from './StyledWrapper';
import MenuDropdown from 'ui/MenuDropdown/index';
const ManageWorkspace = () => {
const dispatch = useDispatch();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const [renameWorkspaceModal, setRenameWorkspaceModal] = useState({ open: false, workspace: null });
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null });
const sortedWorkspaces = useMemo(() => {
return sortWorkspaces(workspaces, preferences);
}, [workspaces, preferences]);
const handleBack = () => {
dispatch(showHomePage());
};
const handleOpenWorkspace = (workspace) => {
dispatch(switchWorkspace(workspace.uid));
dispatch(showHomePage());
toast.success(`Switched to ${workspace.name}`);
};
const handleShowInFolder = (workspace) => {
if (workspace.pathname) {
dispatch(showInFolder(workspace.pathname)).catch(() => {
toast.error('Error opening the folder');
});
}
};
const handleRenameClick = (workspace) => {
setRenameWorkspaceModal({ open: true, workspace });
};
const handleCloseClick = (workspace) => {
if (workspace.type === 'default') {
toast.error('Cannot remove the default workspace');
return;
}
setDeleteWorkspaceModal({ open: true, workspace });
};
return (
<StyledWrapper>
{createWorkspaceModalOpen && (
<CreateWorkspace onClose={() => setCreateWorkspaceModalOpen(false)} />
)}
{renameWorkspaceModal.open && renameWorkspaceModal.workspace && (
<RenameWorkspace
workspace={renameWorkspaceModal.workspace}
onClose={() => setRenameWorkspaceModal({ open: false, workspace: null })}
/>
)}
{deleteWorkspaceModal.open && deleteWorkspaceModal.workspace && (
<DeleteWorkspace
workspace={deleteWorkspaceModal.workspace}
onClose={() => setDeleteWorkspaceModal({ open: false, workspace: null })}
/>
)}
<div className="manage-workspace-header">
<div className="header-left">
<div className="back-button" onClick={handleBack}>
<IconArrowLeft size={18} strokeWidth={1.5} />
</div>
<span className="header-title">Manage Workspace</span>
</div>
<button className="create-workspace-btn" onClick={() => setCreateWorkspaceModalOpen(true)}>
<IconPlus size={14} strokeWidth={2} />
<span>Create Workspace</span>
</button>
</div>
<div className="workspace-list">
{sortedWorkspaces.length === 0 ? (
<div className="empty-state">
<span>No workspaces found</span>
</div>
) : (
sortedWorkspaces.map((workspace) => {
const isDefault = workspace.type === 'default';
const isActive = workspace.uid === activeWorkspaceUid;
return (
<div key={workspace.uid} className="workspace-item">
<div className="workspace-info">
<div className="workspace-name-row">
<span className={`workspace-icon ${isDefault ? 'default' : 'regular'}`}>
{isDefault ? (
<IconLock size={14} strokeWidth={1.5} />
) : (
<IconCategory size={14} strokeWidth={1.5} />
)}
</span>
<span className="workspace-name">{workspace.name}</span>
{isDefault && <span className="default-badge">Default</span>}
</div>
{workspace.pathname && (
<div className="workspace-path">{workspace.pathname}</div>
)}
</div>
<div className="workspace-actions">
<button
className="action-btn"
onClick={() => handleOpenWorkspace(workspace)}
>
<IconLogin size={14} strokeWidth={1.5} />
<span>Open</span>
</button>
{workspace.pathname && workspace.type !== 'default' && (
<button
className="action-btn"
onClick={() => handleShowInFolder(workspace)}
>
<IconFolder size={14} strokeWidth={1.5} />
<span>Show in folder</span>
</button>
)}
{!isDefault && (
<MenuDropdown
placement="bottom-end"
items={[
{ id: 'rename', label: 'Rename', onClick: () => handleRenameClick(workspace) },
{ id: 'remove', label: 'Remove', onClick: () => handleCloseClick(workspace) }
]}
>
<button className="more-actions-btn">
<IconDots size={14} strokeWidth={1.5} />
</button>
</MenuDropdown>
)}
</div>
</div>
);
})
)}
</div>
</StyledWrapper>
);
};
export default ManageWorkspace;

View File

@@ -69,7 +69,7 @@ const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-semibold">
<label htmlFor="environment-name" className="block font-semibold">
Environment Name
</label>
<div className="flex items-center mt-2">

View File

@@ -73,7 +73,7 @@ const RenameEnvironment = ({ onClose, environment }) => {
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
<label htmlFor="name" className="block font-semibold">
<label htmlFor="environment-name" className="block font-semibold">
Environment Name
</label>
<input

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import classnames from 'classnames';
import WorkspaceHome from 'components/WorkspaceHome';
import ManageWorkspace from 'components/ManageWorkspace';
import RequestTabs from 'components/RequestTabs';
import RequestTabPanel from 'components/RequestTabPanel';
import Sidebar from 'components/Sidebar';
@@ -58,6 +59,7 @@ export default function Main() {
const isDragging = useSelector((state) => state.app.isDragging);
const showHomePage = useSelector((state) => state.app.showHomePage);
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
const mainSectionRef = useRef(null);
const [showRosettaBanner, setShowRosettaBanner] = useState(false);
@@ -119,6 +121,8 @@ export default function Main() {
<section className="flex flex-grow flex-col overflow-hidden">
{showApiSpecPage && activeApiSpecUid ? (
<ApiSpecPanel key={activeApiSpecUid} />
) : showManageWorkspacePage ? (
<ManageWorkspace />
) : showHomePage ? (
<WorkspaceHome />
) : (

View File

@@ -11,6 +11,7 @@ const initialState = {
showHomePage: false,
showPreferences: false,
showApiSpecPage: false,
showManageWorkspacePage: false,
isEnvironmentSettingsModalOpen: false,
isGlobalEnvironmentSettingsModalOpen: false,
preferences: {
@@ -77,10 +78,19 @@ export const appSlice = createSlice({
showHomePage: (state) => {
state.showHomePage = true;
state.showApiSpecPage = false;
state.showManageWorkspacePage = false;
},
hideHomePage: (state) => {
state.showHomePage = false;
},
showManageWorkspacePage: (state) => {
state.showManageWorkspacePage = true;
state.showHomePage = false;
state.showApiSpecPage = false;
},
hideManageWorkspacePage: (state) => {
state.showManageWorkspacePage = false;
},
showApiSpecPage: (state) => {
state.showHomePage = false;
state.showPreferences = false;
@@ -135,6 +145,8 @@ export const {
updateGlobalEnvironmentSettingsModalVisibility,
showHomePage,
hideHomePage,
showManageWorkspacePage,
hideManageWorkspacePage,
showApiSpecPage,
hideApiSpecPage,
showPreferences,