mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
redesign: workspace overview (#6361)
* redesign: workspace overview * fixes * fix: test
This commit is contained in:
@@ -1,28 +1,48 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.workspace-rename-container {
|
||||
height: 28px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.workspace-name-input {
|
||||
padding: 0 8px;
|
||||
font-size: 18px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
color: ${(props) => props.theme.text};
|
||||
outline: none;
|
||||
min-width: 200px;
|
||||
min-width: 180px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
@@ -31,7 +51,7 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.inline-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.inline-action-btn {
|
||||
@@ -41,21 +61,21 @@ const StyledWrapper = styled.div`
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.15s;
|
||||
|
||||
&.save {
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.bg.green};
|
||||
background: ${(props) => props.theme.colors.text.green}1A;
|
||||
}
|
||||
}
|
||||
|
||||
&.cancel {
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.colors.bg.red};
|
||||
background: ${(props) => props.theme.colors.text.danger}1A;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,71 +83,61 @@ const StyledWrapper = styled.div`
|
||||
.workspace-error {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 16px;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.colors.text.red};
|
||||
font-size: 11px;
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
|
||||
.workspace-menu-dropdown {
|
||||
min-width: 150px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
transition: background 0.15s;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
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);
|
||||
padding: 8px 0;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s ease;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text.primary};
|
||||
border-bottom-color: ${(props) => props.theme.colors.border};
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-bottom-color: ${(props) => props.theme.colors.text.yellow};
|
||||
color: ${(props) => props.theme.tabs.active.color};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-bottom-color: ${(props) => props.theme.workspace.accent};
|
||||
}
|
||||
}
|
||||
|
||||
.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};
|
||||
}
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
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;
|
||||
@@ -1,351 +0,0 @@
|
||||
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;
|
||||
@@ -1,8 +1,145 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
.editing-mode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
.docs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid ${(props) => props.theme.workspace.border};
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.docs-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 6px 14px;
|
||||
background: ${(props) => props.theme.button.secondary.bg};
|
||||
color: ${(props) => props.theme.button.secondary.color};
|
||||
border: 1px solid ${(props) => props.theme.button.secondary.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.sm};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
|
||||
}
|
||||
}
|
||||
|
||||
.docs-markdown {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 32px 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.empty-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 8px;
|
||||
background: ${(props) => props.theme.workspace.card.bg};
|
||||
border: 1px solid ${(props) => props.theme.workspace.border};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-bottom: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.empty-subtext {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 20px 0;
|
||||
text-align: center;
|
||||
|
||||
li {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
padding: 2px 0;
|
||||
|
||||
&::before {
|
||||
content: '\\2022';
|
||||
color: ${(props) => props.theme.workspace.accent};
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-docs-btn {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.workspace.accent};
|
||||
border: 1px solid ${(props) => props.theme.workspace.accent};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.workspace.accent}14;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { saveWorkspaceDocs } from 'providers/ReduxStore/slices/workspaces/action
|
||||
import Markdown from 'components/MarkDown';
|
||||
import CodeEditor from 'components/CodeEditor';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
|
||||
import { IconFileText, IconEdit, IconX } from '@tabler/icons';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const WorkspaceDocs = ({ workspace }) => {
|
||||
@@ -51,31 +51,34 @@ const WorkspaceDocs = ({ workspace }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDocumentation = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const hasDocs = localDocs && localDocs.trim().length > 0;
|
||||
|
||||
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
|
||||
<StyledWrapper className="h-full w-full flex flex-col">
|
||||
<div className="docs-header">
|
||||
<div className="docs-title">
|
||||
<IconFileText size={16} strokeWidth={1.5} />
|
||||
<span>Documentation</span>
|
||||
</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
|
||||
{hasDocs && !isEditing && (
|
||||
<button className="edit-btn" onClick={toggleViewMode}>
|
||||
<IconEdit size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<button className="edit-btn" onClick={handleDiscardChanges}>
|
||||
<IconX size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
|
||||
<IconEdit className="cursor-pointer" size={20} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="docs-content">
|
||||
{isEditing ? (
|
||||
<div className="editor-container">
|
||||
<CodeEditor
|
||||
theme={displayedTheme}
|
||||
value={localDocs}
|
||||
@@ -85,63 +88,39 @@ const WorkspaceDocs = ({ workspace }) => {
|
||||
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 className="editor-actions">
|
||||
<button className="save-btn" onClick={onSave}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : hasDocs ? (
|
||||
<div className="docs-markdown">
|
||||
<Markdown collectionPath={workspace?.pathname || ''} onDoubleClick={toggleViewMode} content={localDocs} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon-wrapper">
|
||||
<IconFileText size={28} strokeWidth={1} />
|
||||
</div>
|
||||
<p className="empty-text">
|
||||
Add documentation to help your team work smoothly.
|
||||
</p>
|
||||
<p className="empty-subtext">You can include:</p>
|
||||
<ul className="suggestions-list">
|
||||
<li>Project overview</li>
|
||||
<li>Setup instructions</li>
|
||||
<li>Key workflows</li>
|
||||
<li>Resources & FAQs</li>
|
||||
</ul>
|
||||
<button className="add-docs-btn" onClick={handleAddDocumentation}>
|
||||
Add Documentation
|
||||
</button>
|
||||
</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,123 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
.collections-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: ${(props) => props.theme.font.size.md};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.text};
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.collection-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
background: ${(props) => props.theme.workspace.card.bg};
|
||||
border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.hoverBg};
|
||||
cursor: pointer;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.collection-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${(props) => props.theme.workspace.accent};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.collection-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.collection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
.collection-name {
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.text};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.collection-path {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.collection-menu {
|
||||
flex-shrink: 0;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
}
|
||||
|
||||
.collection-dropdown {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.listItem.hoverBg};
|
||||
}
|
||||
|
||||
&.dropdown-item-danger {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,314 @@
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconBox, IconTrash, IconEdit, IconShare, IconDots } from '@tabler/icons';
|
||||
import { removeCollectionFromWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { hideHomePage } from 'providers/ReduxStore/slices/app';
|
||||
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { normalizePath } from 'utils/common/path';
|
||||
import toast from 'react-hot-toast';
|
||||
import Modal from 'components/Modal';
|
||||
import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
|
||||
import ShareCollection from 'components/ShareCollection';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollectionsList = ({ workspace }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
const dropdownRefs = useRef({});
|
||||
|
||||
const [collectionToRemove, setCollectionToRemove] = useState(null);
|
||||
const [renameCollectionModalOpen, setRenameCollectionModalOpen] = useState(false);
|
||||
const [shareCollectionModalOpen, setShareCollectionModalOpen] = useState(false);
|
||||
const [selectedCollectionUid, setSelectedCollectionUid] = useState(null);
|
||||
|
||||
const workspaceCollections = useMemo(() => {
|
||||
if (!workspace.collections || workspace.collections.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return workspace.collections.map((wc) => {
|
||||
const loadedCollection = collections.find(
|
||||
(c) => normalizePath(c.pathname) === normalizePath(wc.path)
|
||||
);
|
||||
|
||||
if (loadedCollection) {
|
||||
return {
|
||||
...loadedCollection,
|
||||
isGitBacked: !!wc.remote,
|
||||
gitRemoteUrl: wc.remote
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
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: ''
|
||||
}
|
||||
};
|
||||
});
|
||||
}, [workspace.collections, collections]);
|
||||
|
||||
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 (Object.prototype.hasOwnProperty.call(collection, 'isGitBacked')) {
|
||||
return {
|
||||
isGitBacked: collection.isGitBacked,
|
||||
gitRemoteUrl: collection.gitRemoteUrl,
|
||||
isLoaded: collection.isLoaded !== false,
|
||||
isInternal: isInternalCollection(collection)
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceCollection = workspace.collections?.find(
|
||||
(wc) => normalizePath(collection.pathname) === normalizePath(wc.path)
|
||||
);
|
||||
|
||||
return {
|
||||
isGitBacked: !!workspaceCollection?.remote,
|
||||
gitRemoteUrl: workspaceCollection?.remote,
|
||||
isLoaded: true,
|
||||
isInternal: isInternalCollection(collection)
|
||||
};
|
||||
};
|
||||
|
||||
const handleOpenCollectionClick = (collection, event) => {
|
||||
if (event.target.closest('.collection-menu')) {
|
||||
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) => {
|
||||
dropdownRefs.current[collection.uid]?.hide();
|
||||
if (collection.isLoaded === false) {
|
||||
toast.error('Cannot rename collections that are not cloned yet');
|
||||
return;
|
||||
}
|
||||
setSelectedCollectionUid(collection.uid);
|
||||
setRenameCollectionModalOpen(true);
|
||||
};
|
||||
|
||||
const handleShareCollection = (collection) => {
|
||||
dropdownRefs.current[collection.uid]?.hide();
|
||||
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) => {
|
||||
dropdownRefs.current[collection.uid]?.hide();
|
||||
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 renderRemoveModal = () => {
|
||||
if (!collectionToRemove) return null;
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{renameCollectionModalOpen && selectedCollectionUid && (
|
||||
<RenameCollection
|
||||
collectionUid={selectedCollectionUid}
|
||||
onClose={() => {
|
||||
setRenameCollectionModalOpen(false);
|
||||
setSelectedCollectionUid(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shareCollectionModalOpen && selectedCollectionUid && (
|
||||
<ShareCollection
|
||||
collectionUid={selectedCollectionUid}
|
||||
onClose={() => {
|
||||
setShareCollectionModalOpen(false);
|
||||
setSelectedCollectionUid(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renderRemoveModal()}
|
||||
|
||||
<div className="collections-list">
|
||||
{workspaceCollections.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<IconBox size={32} strokeWidth={1.5} className="empty-icon" />
|
||||
<h3 className="empty-title">No collections yet</h3>
|
||||
<p className="empty-description">
|
||||
Create your first collection or open an existing one to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
workspaceCollections.map((collection, index) => (
|
||||
<div
|
||||
key={collection.uid || index}
|
||||
className="collection-card"
|
||||
onClick={(e) => handleOpenCollectionClick(collection, e)}
|
||||
>
|
||||
<div className="collection-info">
|
||||
<div className="collection-header">
|
||||
<div className="collection-icon-wrapper">
|
||||
<IconBox size={18} strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="collection-name">{collection.name}</div>
|
||||
</div>
|
||||
<div className="collection-path">{collection.pathname}</div>
|
||||
</div>
|
||||
<div className="collection-menu">
|
||||
<Dropdown
|
||||
style="new"
|
||||
placement="bottom-end"
|
||||
onCreate={(ref) => (dropdownRefs.current[collection.uid] = ref)}
|
||||
icon={<IconDots size={18} strokeWidth={1.5} />}
|
||||
>
|
||||
<div className="collection-dropdown">
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRenameCollection(collection);
|
||||
}}
|
||||
>
|
||||
<IconEdit size={16} strokeWidth={1.5} />
|
||||
<span>Rename</span>
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleShareCollection(collection);
|
||||
}}
|
||||
>
|
||||
<IconShare size={16} strokeWidth={1.5} />
|
||||
<span>Share</span>
|
||||
</div>
|
||||
<div
|
||||
className="dropdown-item dropdown-item-danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveCollection(collection);
|
||||
}}
|
||||
>
|
||||
<IconTrash size={16} strokeWidth={1.5} />
|
||||
<span>Remove</span>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionsList;
|
||||
@@ -0,0 +1,92 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
height: 100%;
|
||||
|
||||
.overview-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overview-main {
|
||||
flex: 3;
|
||||
padding: 20px 16px 16px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid ${(props) => props.theme.workspace.border};
|
||||
}
|
||||
|
||||
.overview-docs {
|
||||
display: flex;
|
||||
flex: 2;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.text};
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
.quick-actions-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.quick-actions-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid ${(props) => props.theme.workspace.accent};
|
||||
border-radius: ${(props) => props.theme.border.radius.base};
|
||||
background: transparent;
|
||||
color: ${(props) => props.theme.workspace.accent};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.workspace.accent}14;
|
||||
}
|
||||
}
|
||||
|
||||
.collections-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { IconPlus, IconFolder, IconFileImport } from '@tabler/icons';
|
||||
import { importCollectionInWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { 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 CollectionsList from './CollectionsList';
|
||||
import WorkspaceDocs from '../WorkspaceDocs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const WorkspaceOverview = ({ workspace }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { collections } = useSelector((state) => state.collections);
|
||||
|
||||
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
|
||||
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
|
||||
|
||||
const workspaceCollectionsCount = workspace?.collections?.length || 0;
|
||||
|
||||
const workspaceEnvironmentsCount = useMemo(() => {
|
||||
if (!workspace?.collections || !collections) return 0;
|
||||
let count = 0;
|
||||
workspace.collections.forEach((wc) => {
|
||||
const loadedCollection = collections.find((c) => c.pathname === wc.path);
|
||||
if (loadedCollection?.environments) {
|
||||
count += loadedCollection.environments.length;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}, [workspace?.collections, collections]);
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
if (!workspace?.pathname) {
|
||||
toast.error('Workspace path not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { ipcRenderer } = window;
|
||||
await ipcRenderer.invoke('renderer:ensure-collections-folder', workspace.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 }) => {
|
||||
setImportCollectionModalOpen(false);
|
||||
dispatch(importCollectionInWorkspace(rawData, workspace.uid, undefined, type)).catch((err) => {
|
||||
console.error(err);
|
||||
toast.error('An error occurred while importing the collection');
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{createCollectionModalOpen && (
|
||||
<CreateCollection onClose={() => setCreateCollectionModalOpen(false)} />
|
||||
)}
|
||||
|
||||
{importCollectionModalOpen && (
|
||||
<ImportCollection
|
||||
onClose={() => setImportCollectionModalOpen(false)}
|
||||
handleSubmit={handleImportCollectionSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="overview-layout">
|
||||
<div className="overview-main">
|
||||
<div className="stats-row">
|
||||
<div className="stat-item">
|
||||
<span className="stat-value">{workspaceCollectionsCount}</span>
|
||||
<span className="stat-label">Collections</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-value">{workspaceEnvironmentsCount}</span>
|
||||
<span className="stat-label">Environments</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quick-actions-section">
|
||||
<div className="section-title">Quick Actions</div>
|
||||
<div className="quick-actions-buttons">
|
||||
<button className="quick-action-btn" onClick={handleCreateCollection}>
|
||||
<IconPlus size={14} strokeWidth={1.5} />
|
||||
<span>Create Collection</span>
|
||||
</button>
|
||||
<button className="quick-action-btn" onClick={handleOpenCollection}>
|
||||
<IconFolder size={14} strokeWidth={1.5} />
|
||||
<span>Open Collection</span>
|
||||
</button>
|
||||
<button className="quick-action-btn" onClick={handleImportCollection}>
|
||||
<IconFileImport size={14} strokeWidth={1.5} />
|
||||
<span>Import Collection</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="collections-section">
|
||||
<div className="section-title">Collections</div>
|
||||
<CollectionsList workspace={workspace} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overview-docs">
|
||||
<WorkspaceDocs workspace={workspace} />
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceOverview;
|
||||
@@ -1,14 +1,11 @@
|
||||
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 { IconCategory, IconDots, IconEdit, IconX, IconCheck, IconFolder } from '@tabler/icons';
|
||||
import { renameWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
|
||||
import { showInFolder } 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 WorkspaceOverview from './WorkspaceOverview';
|
||||
import WorkspaceEnvironments from './WorkspaceEnvironments';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
@@ -16,10 +13,7 @@ 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 [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
|
||||
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
|
||||
@@ -51,40 +45,8 @@ const WorkspaceHome = () => {
|
||||
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 = () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
setIsRenamingWorkspace(true);
|
||||
setWorkspaceNameInput(activeWorkspace.name);
|
||||
setWorkspaceNameError('');
|
||||
@@ -106,9 +68,7 @@ const WorkspaceHome = () => {
|
||||
const handleShowInFolder = () => {
|
||||
dropdownTippyRef.current?.hide();
|
||||
if (activeWorkspace.pathname) {
|
||||
dispatch(showInFolder(activeWorkspace.pathname))
|
||||
.catch((error) => {
|
||||
console.error('Error opening the folder', error);
|
||||
dispatch(showInFolder(activeWorkspace.pathname)).catch((error) => {
|
||||
toast.error('Error opening the folder');
|
||||
});
|
||||
}
|
||||
@@ -118,15 +78,12 @@ const WorkspaceHome = () => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -157,9 +114,7 @@ const WorkspaceHome = () => {
|
||||
};
|
||||
|
||||
const handleWorkspaceNameChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setWorkspaceNameInput(value);
|
||||
|
||||
setWorkspaceNameInput(e.target.value);
|
||||
if (workspaceNameError) {
|
||||
setWorkspaceNameError('');
|
||||
}
|
||||
@@ -175,52 +130,36 @@ const WorkspaceHome = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeWorkspace) {
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'environments', label: 'Environments' }
|
||||
];
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'overview':
|
||||
return <WorkspaceOverview workspace={activeWorkspace} />;
|
||||
case 'environments':
|
||||
return <WorkspaceEnvironments workspace={activeWorkspace} />;
|
||||
default:
|
||||
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)}
|
||||
<div className="h-full flex flex-row">
|
||||
{closeWorkspaceModalOpen && (
|
||||
<CloseWorkspace
|
||||
workspaceUid={activeWorkspace.uid}
|
||||
onClose={() => setCloseWorkspaceModalOpen(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} />
|
||||
<div className="main-content">
|
||||
<div className="workspace-header">
|
||||
<div className="workspace-title">
|
||||
<IconCategory size={20} strokeWidth={1.5} />
|
||||
{isRenamingWorkspace ? (
|
||||
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
|
||||
<input
|
||||
@@ -264,7 +203,7 @@ const WorkspaceHome = () => {
|
||||
style="new"
|
||||
placement="bottom-end"
|
||||
onCreate={onDropdownCreate}
|
||||
icon={<IconDots size={20} strokeWidth={1.5} className="cursor-pointer" />}
|
||||
icon={<IconDots size={18} strokeWidth={1.5} className="cursor-pointer" />}
|
||||
>
|
||||
<div className="workspace-menu-dropdown">
|
||||
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
|
||||
@@ -288,60 +227,19 @@ const WorkspaceHome = () => {
|
||||
)}
|
||||
</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 (
|
||||
<div className="tabs-container">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`tab-item ${activeTab === tab.id ? 'active' : ''}`}
|
||||
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 className="tab-content">{renderTabContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -121,7 +121,7 @@ test.describe('DevTools Performance Tab', () => {
|
||||
await expect(performanceTab).not.toHaveClass(/active/);
|
||||
|
||||
// Verify Console tab content is shown
|
||||
await expect(page.locator('.tab-content')).toBeVisible();
|
||||
await expect(page.locator('.console-empty')).toBeVisible();
|
||||
|
||||
// Switch back to Performance tab
|
||||
await performanceTab.click();
|
||||
|
||||
Reference in New Issue
Block a user