From 43f24ad0f1a93a5b546a149851d4ba712e365a73 Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Wed, 10 Dec 2025 02:56:28 +0530 Subject: [PATCH] redesign: workspace overview (#6361) * redesign: workspace overview * fixes * fix: test --- .../components/WorkspaceHome/StyledWrapper.js | 102 ++--- .../WorkspaceCollections/StyledWrapper.js | 154 -------- .../WorkspaceCollections/index.js | 351 ------------------ .../WorkspaceDocs/StyledWrapper.js | 139 ++++++- .../WorkspaceHome/WorkspaceDocs/index.js | 147 ++++---- .../CollectionsList/StyledWrapper.js | 123 ++++++ .../CollectionsList/index.js | 314 ++++++++++++++++ .../WorkspaceOverview/StyledWrapper.js | 92 +++++ .../WorkspaceHome/WorkspaceOverview/index.js | 127 +++++++ .../src/components/WorkspaceHome/index.js | 306 +++++---------- .../performance/performance-tab.spec.ts | 2 +- 11 files changed, 1016 insertions(+), 841 deletions(-) delete mode 100644 packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/StyledWrapper.js delete mode 100644 packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/index.js create mode 100644 packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js create mode 100644 packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js diff --git a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js index 7e800b754..ad71514d4 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js @@ -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; } `; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/StyledWrapper.js deleted file mode 100644 index 1c464a0d6..000000000 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/StyledWrapper.js +++ /dev/null @@ -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; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/index.js deleted file mode 100644 index 91bac29bd..000000000 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceCollections/index.js +++ /dev/null @@ -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 ( - -
- - {createCollectionModalOpen && ( - setCreateCollectionModalOpen(false)} - /> - )} - - {importCollectionModalOpen && ( - setImportCollectionModalOpen(false)} - handleSubmit={handleImportCollection} - /> - )} - - {renameCollectionModalOpen && selectedCollectionUid && ( - { - setRenameCollectionModalOpen(false); - setSelectedCollectionUid(null); - }} - /> - )} - - {shareCollectionModalOpen && selectedCollectionUid && ( - { - setShareCollectionModalOpen(false); - setSelectedCollectionUid(null); - }} - /> - )} - - {collectionToRemove && (() => { - const collectionInfo = getCollectionWorkspaceInfo(collectionToRemove); - const isDelete = collectionInfo.isInternal && !collectionInfo.isGitBacked; - - return ( - setCollectionToRemove(null)} - handleConfirm={confirmRemoveCollection} - confirmText={isDelete ? 'Delete' : 'Remove'} - cancelText="Cancel" - style="new" - > -

- Are you sure you want to {isDelete ? 'delete' : 'remove'} "{collectionToRemove.name}"? -

-

- {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.'} -

-
- ); - })()} - -
- {workspaceCollections.length === 0 ? ( -
-
- -
-

No collections yet

-

- Create your first collection or open an existing one to get started. -

-
- ) : ( -
-
-
Collection
-
Location
-
Actions
-
- -
- {workspaceCollections.map((collection, index) => { - return ( -
handleOpenCollectionClick(collection, e)} - > -
-
- -
-
{collection.name}
- {collection.brunoConfig?.name && collection.brunoConfig.name !== collection.name && ( -
{collection.brunoConfig.name}
- )} -
-
-
- -
-
- {collection.pathname} -
-
- -
-
- - - -
-
-
- ); - })} -
-
- )} -
-
-
- ); -}; - -export default WorkspaceCollections; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/StyledWrapper.js index af80d4c08..29069de23 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/StyledWrapper.js @@ -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; + } } `; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/index.js index f676070ca..b69047d02 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceDocs/index.js @@ -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,97 +51,76 @@ const WorkspaceDocs = ({ workspace }) => { } }; + const handleAddDocumentation = () => { + setIsEditing(true); + }; + + const hasDocs = localDocs && localDocs.trim().length > 0; + return ( - -
-
- - Workspace Documentation + +
+
+ + Documentation
-
- {isEditing ? ( - <> -
- -
- + )} + {isEditing && ( + + )} +
+ +
+ {isEditing ? ( +
+ +
+ - - ) : ( -
-
- )} -
-
- {isEditing ? ( - - ) : ( -
-
- { - localDocs?.length > 0 - ? - : - }
-
- )} + ) : hasDocs ? ( +
+ +
+ ) : ( +
+
+ +
+

+ Add documentation to help your team work smoothly. +

+

You can include:

+
    +
  • Project overview
  • +
  • Setup instructions
  • +
  • Key workflows
  • +
  • Resources & FAQs
  • +
+ +
+ )} +
); }; 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! -`; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js new file mode 100644 index 000000000..f085487ae --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js new file mode 100644 index 000000000..0d228df93 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/CollectionsList/index.js @@ -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 ( + setCollectionToRemove(null)} + handleConfirm={confirmRemoveCollection} + confirmText={isDelete ? 'Delete' : 'Remove'} + cancelText="Cancel" + style="new" + > +

+ Are you sure you want to {isDelete ? 'delete' : 'remove'}{' '} + "{collectionToRemove.name}"? +

+

+ {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.'} +

+
+ ); + }; + + return ( + + {renameCollectionModalOpen && selectedCollectionUid && ( + { + setRenameCollectionModalOpen(false); + setSelectedCollectionUid(null); + }} + /> + )} + + {shareCollectionModalOpen && selectedCollectionUid && ( + { + setShareCollectionModalOpen(false); + setSelectedCollectionUid(null); + }} + /> + )} + + {renderRemoveModal()} + +
+ {workspaceCollections.length === 0 ? ( +
+ +

No collections yet

+

+ Create your first collection or open an existing one to get started. +

+
+ ) : ( + workspaceCollections.map((collection, index) => ( +
handleOpenCollectionClick(collection, e)} + > +
+
+
+ +
+
{collection.name}
+
+
{collection.pathname}
+
+
+ (dropdownRefs.current[collection.uid] = ref)} + icon={} + > +
+
{ + e.stopPropagation(); + handleRenameCollection(collection); + }} + > + + Rename +
+
{ + e.stopPropagation(); + handleShareCollection(collection); + }} + > + + Share +
+
{ + e.stopPropagation(); + handleRemoveCollection(collection); + }} + > + + Remove +
+
+
+
+
+ )) + )} +
+
+ ); +}; + +export default CollectionsList; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/StyledWrapper.js new file mode 100644 index 000000000..f1b1dff6d --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js new file mode 100644 index 000000000..d426f7f97 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js @@ -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 ( + + {createCollectionModalOpen && ( + setCreateCollectionModalOpen(false)} /> + )} + + {importCollectionModalOpen && ( + setImportCollectionModalOpen(false)} + handleSubmit={handleImportCollectionSubmit} + /> + )} + +
+
+
+
+ {workspaceCollectionsCount} + Collections +
+
+ {workspaceEnvironmentsCount} + Environments +
+
+ +
+
Quick Actions
+
+ + + +
+
+ +
+
Collections
+ +
+
+ +
+ +
+
+
+ ); +}; + +export default WorkspaceOverview; diff --git a/packages/bruno-app/src/components/WorkspaceHome/index.js b/packages/bruno-app/src/components/WorkspaceHome/index.js index 0901f3d8b..ed7340901 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/index.js @@ -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,11 +68,9 @@ const WorkspaceHome = () => { 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'); - }); + 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,119 +130,25 @@ const WorkspaceHome = () => { } }; - if (!activeWorkspace) { - return null; - } - const tabs = [ - { - id: 'collections', - label: 'Collections', - component: ( - - ) - }, - { - id: 'environments', - label: 'Environments', - component: - }, - { - id: 'documentation', - label: 'Documentation', - component: - } + { id: 'overview', label: 'Overview' }, + { id: 'environments', label: 'Environments' } ]; + const renderTabContent = () => { + switch (activeTab) { + case 'overview': + return ; + case 'environments': + return ; + default: + return null; + } + }; + return ( -
- {createCollectionModalOpen && ( - setCreateCollectionModalOpen(false)} - /> - )} - - {importCollectionModalOpen && ( - setImportCollectionModalOpen(false)} - handleSubmit={handleImportCollectionSubmit} - /> - )} - -
-
- - {isRenamingWorkspace ? ( -
- -
- - -
-
- ) : ( - {activeWorkspace.name} - )} -
- - {!isRenamingWorkspace && activeWorkspace.type !== 'default' && ( - } - > -
-
- - Rename -
-
- - Show in Folder -
-
- - Close -
-
-
- )} - - {workspaceNameError && isRenamingWorkspace && ( -
{workspaceNameError}
- )} -
- +
{closeWorkspaceModalOpen && ( { /> )} -
-
- {tabs.map((tab) => { - return ( - - ); - })} +
+
+
+ + {isRenamingWorkspace ? ( +
+ +
+ + +
+
+ ) : ( + {activeWorkspace.name} + )} +
+ + {!isRenamingWorkspace && activeWorkspace.type !== 'default' && ( + } + > +
+
+ + Rename +
+
+ + Show in Folder +
+
+ + Close +
+
+
+ )} + + {workspaceNameError && isRenamingWorkspace && ( +
{workspaceNameError}
+ )}
- {activeTab === 'collections' && ( -
+
+ {tabs.map((tab) => ( - - -
- )} -
+ ))} +
-
- {tabs.find((tab) => tab.id === activeTab)?.component} +
{renderTabContent()}
diff --git a/tests/devtools/performance/performance-tab.spec.ts b/tests/devtools/performance/performance-tab.spec.ts index 8e71f4b17..75719c640 100644 --- a/tests/devtools/performance/performance-tab.spec.ts +++ b/tests/devtools/performance/performance-tab.spec.ts @@ -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();