From a1c411389723a588555c954f2718bf752ae9240c Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Thu, 18 Dec 2025 21:01:58 +0530 Subject: [PATCH] add: workspace tabs (#6456) * add: workspace tabs * fixes * fixes --- .../components/WorkspaceHome/StyledWrapper.js | 27 --- .../src/components/WorkspaceHome/index.js | 26 +-- .../components/WorkspaceTabs/StyledWrapper.js | 198 ++++++++++++++++++ .../WorkspaceTab/StyledWrapper.js | 61 ++++++ .../WorkspaceTabs/WorkspaceTab/index.js | 44 ++++ .../src/components/WorkspaceTabs/index.js | 158 ++++++++++++++ .../src/providers/ReduxStore/index.js | 2 + .../ReduxStore/slices/workspaceTabs.js | 194 +++++++++++++++++ .../ReduxStore/slices/workspaces/actions.js | 8 + 9 files changed, 673 insertions(+), 45 deletions(-) create mode 100644 packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js create mode 100644 packages/bruno-app/src/components/WorkspaceTabs/index.js create mode 100644 packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js diff --git a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js index ad71514d4..dbc673a8d 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js +++ b/packages/bruno-app/src/components/WorkspaceHome/StyledWrapper.js @@ -108,33 +108,6 @@ const StyledWrapper = styled.div` } } - .tabs-container { - display: flex; - gap: 16px; - padding: 0 16px; - border-bottom: 1px solid ${(props) => props.theme.workspace.border}; - } - - .tab-item { - 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; - cursor: pointer; - transition: all 0.15s; - - &:hover { - color: ${(props) => props.theme.text}; - } - - &.active { - color: ${(props) => props.theme.text}; - border-bottom-color: ${(props) => props.theme.workspace.accent}; - } - } - .tab-content { flex: 1; overflow: hidden; diff --git a/packages/bruno-app/src/components/WorkspaceHome/index.js b/packages/bruno-app/src/components/WorkspaceHome/index.js index d28a2c4ab..08c2ec0fa 100644 --- a/packages/bruno-app/src/components/WorkspaceHome/index.js +++ b/packages/bruno-app/src/components/WorkspaceHome/index.js @@ -7,13 +7,16 @@ import toast from 'react-hot-toast'; import CloseWorkspace from 'components/Sidebar/CloseWorkspace'; import WorkspaceOverview from './WorkspaceOverview'; import WorkspaceEnvironments from './WorkspaceEnvironments'; +import WorkspaceTabs from 'components/WorkspaceTabs'; import StyledWrapper from './StyledWrapper'; import Dropdown from 'components/Dropdown'; const WorkspaceHome = () => { const dispatch = useDispatch(); const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces); - const [activeTab, setActiveTab] = useState('overview'); + const workspaceTabs = useSelector((state) => state.workspaceTabs); + const activeTabUid = workspaceTabs.activeTabUid; + const activeTab = workspaceTabs.tabs.find((t) => t.uid === activeTabUid); const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false); const [workspaceNameInput, setWorkspaceNameInput] = useState(''); @@ -143,13 +146,10 @@ const WorkspaceHome = () => { } }; - const tabs = [ - { id: 'overview', label: 'Overview' }, - { id: 'environments', label: 'Global Environments' } - ]; - const renderTabContent = () => { - switch (activeTab) { + if (!activeTab) return null; + + switch (activeTab.type) { case 'overview': return ; case 'environments': @@ -244,17 +244,7 @@ const WorkspaceHome = () => { )} -
- {tabs.map((tab) => ( - - ))} -
+
{renderTabContent()}
diff --git a/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js new file mode 100644 index 000000000..215e0d2ca --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceTabs/StyledWrapper.js @@ -0,0 +1,198 @@ +import styled from 'styled-components'; + +const Wrapper = styled.div` + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 1px; + background: ${(props) => props.theme.requestTabs.bottomBorder}; + z-index: 0; + } + + .tabs-scroll-container { + overflow-x: auto; + overflow-y: clip; + padding-bottom: 10px; + margin-bottom: -10px; + + &::-webkit-scrollbar { + display: none; + } + + scrollbar-width: none; + + ul { + margin-bottom: 0; + overflow: visible; + } + } + + ul { + padding: 0 3px; + margin: 0; + display: flex; + align-items: flex-end; + position: relative; + + &::-webkit-scrollbar { + display: none; + } + + scrollbar-width: none; + + li { + display: inline-flex; + max-width: 180px; + min-width: 80px; + list-style: none; + cursor: pointer; + font-size: 0.8125rem; + position: relative; + margin-right: 3px; + color: ${(props) => props.theme.requestTabs.color}; + background: transparent; + border: 1px solid transparent; + padding: 6px 0; + flex-shrink: 0; + transition: background-color 0.15s ease; + margin-bottom: 3px; + + .tab-container { + width: 100%; + position: relative; + overflow: hidden; + } + + &:not(.active) { + background: ${(props) => props.theme.requestTabs.bg}; + border-color: transparent; + border-radius: ${(props) => props.theme.border.radius.base}; + } + + &:nth-last-child(1) { + margin-right: 4px; + } + + &.has-overflow:not(:hover) .tab-name { + mask-image: linear-gradient( + to right, + ${(props) => props.theme.requestTabs.color} 0%, + ${(props) => props.theme.requestTabs.color} calc(100% - 12px), + transparent 100% + ); + -webkit-mask-image: linear-gradient( + to right, + ${(props) => props.theme.requestTabs.color} 0%, + ${(props) => props.theme.requestTabs.color} calc(100% - 12px), + transparent 100% + ); + } + + &.has-overflow:hover .tab-name { + mask-image: linear-gradient( + to right, + ${(props) => props.theme.requestTabs.color} 0%, + ${(props) => props.theme.requestTabs.color} calc(100% - 8px), + transparent 100% + ); + -webkit-mask-image: linear-gradient( + to right, + ${(props) => props.theme.requestTabs.color} 0%, + ${(props) => props.theme.requestTabs.color} calc(100% - 8px), + transparent 100% + ); + } + + &.active { + background: ${(props) => props.theme.bg || '#ffffff'}; + border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; + border-bottom-color: ${(props) => props.theme.bg || '#ffffff'}; + border-radius: 8px 8px 0 0; + z-index: 2; + margin-bottom: -2px; + padding-bottom: 12px; + + &::before { + content: ''; + position: absolute; + bottom: 1px; + left: -8px; + width: 8px; + height: 8px; + background: transparent; + border-bottom-right-radius: 6px; + box-shadow: 3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'}; + border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; + border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; + } + + &::after { + content: ''; + position: absolute; + bottom: 1px; + right: -8px; + width: 8px; + height: 8px; + background: transparent; + border-bottom-left-radius: 6px; + box-shadow: -3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'}; + border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; + border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder}; + } + } + + &.permanent-tab { + .close-icon { + display: none; + } + } + + &.short-tab { + width: 32px; + min-width: 32px; + max-width: 32px; + padding: 5px 0; + display: inline-flex; + justify-content: center; + align-items: center; + color: ${(props) => props.theme.requestTabs.shortTab.color}; + background-color: transparent; + border: 1px solid transparent; + border-radius: ${(props) => props.theme.border.radius.base}; + flex-shrink: 0; + + > div { + padding: 3px; + display: flex; + align-items: center; + justify-content: center; + border-radius: ${(props) => props.theme.border.radius.sm}; + transition: background-color 0.12s ease, color 0.12s ease; + } + + svg { + height: 20px; + width: 20px; + } + + &:hover { + > div { + background-color: ${(props) => props.theme.requestTabs.shortTab.hoverBg}; + color: ${(props) => props.theme.requestTabs.shortTab.hoverColor}; + } + } + } + } + } + + &.has-chevrons ul { + padding-left: 0; + } +`; + +export default Wrapper; diff --git a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js new file mode 100644 index 000000000..cadfbb82a --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/StyledWrapper.js @@ -0,0 +1,61 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + position: relative; + width: 100%; + height: 100%; + + .tab-label { + overflow: hidden; + align-items: center; + position: relative; + flex: 1; + min-width: 0; + } + + .tab-icon { + flex-shrink: 0; + display: flex; + align-items: center; + margin-right: 6px; + color: ${(props) => props.theme.requestTabs.color}; + } + + .tab-name { + position: relative; + overflow: hidden; + white-space: nowrap; + font-size: 0.8125rem; + padding-right: 2px; + } + + .close-icon { + margin-left: 6px; + padding: 2px; + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s, background-color 0.15s; + + &:hover { + background-color: ${(props) => props.theme.requestTabs.closeIconHoverBg || 'rgba(0, 0, 0, 0.1)'}; + } + + svg { + width: 14px; + height: 14px; + } + } + + &:hover .close-icon { + opacity: 1; + } + + &.permanent .close-icon { + display: none; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js new file mode 100644 index 000000000..4aabb42ef --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceTabs/WorkspaceTab/index.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { IconX, IconHome, IconWorld } from '@tabler/icons'; +import { useDispatch } from 'react-redux'; +import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs'; +import StyledWrapper from './StyledWrapper'; + +const TAB_ICONS = { + overview: IconHome, + environments: IconWorld +}; + +const WorkspaceTab = ({ tab, isActive }) => { + const dispatch = useDispatch(); + + const handleCloseClick = (event) => { + event.stopPropagation(); + event.preventDefault(); + dispatch(closeWorkspaceTab({ uid: tab.uid })); + }; + + const TabIcon = TAB_ICONS[tab.type]; + + return ( + +
+ {TabIcon && ( + + + + )} + + {tab.label} + +
+ {!tab.permanent && ( +
+ +
+ )} +
+ ); +}; + +export default WorkspaceTab; diff --git a/packages/bruno-app/src/components/WorkspaceTabs/index.js b/packages/bruno-app/src/components/WorkspaceTabs/index.js new file mode 100644 index 000000000..ec32e37e8 --- /dev/null +++ b/packages/bruno-app/src/components/WorkspaceTabs/index.js @@ -0,0 +1,158 @@ +import React, { useState, useRef, useEffect, useCallback } from 'react'; +import filter from 'lodash/filter'; +import classnames from 'classnames'; +import { IconChevronRight, IconChevronLeft } from '@tabler/icons'; +import { useSelector, useDispatch } from 'react-redux'; +import { focusWorkspaceTab, initializeWorkspaceTabs } from 'providers/ReduxStore/slices/workspaceTabs'; +import WorkspaceTab from './WorkspaceTab'; +import StyledWrapper from './StyledWrapper'; + +const PERMANENT_TABS = [ + { type: 'overview', label: 'Overview' }, + { type: 'environments', label: 'Global Environments' } +]; + +const WorkspaceTabs = ({ workspaceUid }) => { + const dispatch = useDispatch(); + const tabsRef = useRef(); + const scrollContainerRef = useRef(); + const [tabOverflowStates, setTabOverflowStates] = useState({}); + const [showChevrons, setShowChevrons] = useState(false); + + const tabs = useSelector((state) => state.workspaceTabs.tabs); + const activeTabUid = useSelector((state) => state.workspaceTabs.activeTabUid); + const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth); + const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed); + const screenWidth = useSelector((state) => state.app.screenWidth); + + // Initialize permanent tabs for this workspace + useEffect(() => { + if (workspaceUid) { + dispatch(initializeWorkspaceTabs({ + workspaceUid, + permanentTabs: PERMANENT_TABS + })); + } + }, [workspaceUid, dispatch]); + + const createSetHasOverflow = useCallback((tabUid) => { + return (hasOverflow) => { + setTabOverflowStates((prev) => { + if (prev[tabUid] === hasOverflow) { + return prev; + } + return { + ...prev, + [tabUid]: hasOverflow + }; + }); + }; + }, []); + + // Filter tabs for this workspace + const workspaceTabs = filter(tabs, (t) => t.workspaceUid === workspaceUid); + + useEffect(() => { + if (!activeTabUid) return; + + const checkOverflow = () => { + if (tabsRef.current && scrollContainerRef.current) { + const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth; + setShowChevrons(hasOverflow); + } + }; + + checkOverflow(); + const resizeObserver = new ResizeObserver(checkOverflow); + if (scrollContainerRef.current) { + resizeObserver.observe(scrollContainerRef.current); + } + + return () => resizeObserver.disconnect(); + }, [activeTabUid, workspaceTabs.length, screenWidth, leftSidebarWidth, sidebarCollapsed]); + + const getTabClassname = (tab, index) => { + return classnames('request-tab select-none', { + 'active': tab.uid === activeTabUid, + 'permanent-tab': tab.permanent, + 'last-tab': workspaceTabs && workspaceTabs.length && index === workspaceTabs.length - 1, + 'has-overflow': tabOverflowStates[tab.uid] + }); + }; + + const handleClick = (tab) => { + dispatch(focusWorkspaceTab({ uid: tab.uid })); + }; + + if (!workspaceUid || workspaceTabs.length === 0) { + return null; + } + + const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth; + const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150; + + const leftSlide = () => { + scrollContainerRef.current?.scrollBy({ + left: -120, + behavior: 'smooth' + }); + }; + + const rightSlide = () => { + scrollContainerRef.current?.scrollBy({ + left: 120, + behavior: 'smooth' + }); + }; + + const getRootClassname = () => { + return classnames({ + 'has-chevrons': showChevrons + }); + }; + + return ( + +
+
    + {showChevrons ? ( +
  • +
    + +
    +
  • + ) : null} +
+
+
    + {workspaceTabs.map((tab, index) => ( +
  • handleClick(tab)} + > + +
  • + ))} +
+
+
    + {showChevrons ? ( +
  • +
    + +
    +
  • + ) : null} +
+
+
+ ); +}; + +export default WorkspaceTabs; diff --git a/packages/bruno-app/src/providers/ReduxStore/index.js b/packages/bruno-app/src/providers/ReduxStore/index.js index 3e17f6ad7..e448c55d7 100644 --- a/packages/bruno-app/src/providers/ReduxStore/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/index.js @@ -4,6 +4,7 @@ import debugMiddleware from './middlewares/debug/middleware'; import appReducer from './slices/app'; import collectionsReducer from './slices/collections'; import tabsReducer from './slices/tabs'; +import workspaceTabsReducer from './slices/workspaceTabs'; import notificationsReducer from './slices/notifications'; import globalEnvironmentsReducer from './slices/global-environments'; import logsReducer from './slices/logs'; @@ -27,6 +28,7 @@ export const store = configureStore({ app: appReducer, collections: collectionsReducer, tabs: tabsReducer, + workspaceTabs: workspaceTabsReducer, notifications: notificationsReducer, globalEnvironments: globalEnvironmentsReducer, logs: logsReducer, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js new file mode 100644 index 000000000..3da436e36 --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaceTabs.js @@ -0,0 +1,194 @@ +import { createSlice } from '@reduxjs/toolkit'; +import filter from 'lodash/filter'; +import find from 'lodash/find'; +import last from 'lodash/last'; + +const initialState = { + tabs: [], + activeTabUid: null +}; + +export const workspaceTabsSlice = createSlice({ + name: 'workspaceTabs', + initialState, + reducers: { + addWorkspaceTab: (state, action) => { + const { uid, workspaceUid, type, label, permanent = false } = action.payload; + + const existingTab = find(state.tabs, (tab) => tab.uid === uid); + if (existingTab) { + state.activeTabUid = existingTab.uid; + return; + } + + // Check if a tab of the same type already exists for this workspace + const existingTypeTab = find( + state.tabs, + (tab) => tab.workspaceUid === workspaceUid && tab.type === type + ); + if (existingTypeTab) { + state.activeTabUid = existingTypeTab.uid; + return; + } + + state.tabs.push({ + uid, + workspaceUid, + type, + label, + permanent + }); + state.activeTabUid = uid; + }, + focusWorkspaceTab: (state, action) => { + state.activeTabUid = action.payload.uid; + }, + closeWorkspaceTab: (state, action) => { + const tabUid = action.payload.uid; + const tab = find(state.tabs, (t) => t.uid === tabUid); + + // Don't allow closing permanent tabs + if (tab?.permanent) { + return; + } + + state.tabs = filter(state.tabs, (t) => t.uid !== tabUid); + + // If we closed the active tab, activate another one + if (state.activeTabUid === tabUid && state.tabs.length > 0) { + state.activeTabUid = last(state.tabs).uid; + } else if (state.tabs.length === 0) { + state.activeTabUid = null; + } + }, + closeWorkspaceTabs: (state, action) => { + const tabUids = action.payload.tabUids || []; + + // Filter out permanent tabs from the close request + const tabsToClose = tabUids.filter((uid) => { + const tab = find(state.tabs, (t) => t.uid === uid); + return tab && !tab.permanent; + }); + + state.tabs = filter(state.tabs, (t) => !tabsToClose.includes(t.uid)); + + // If active tab was closed, activate another one + if (tabsToClose.includes(state.activeTabUid)) { + if (state.tabs.length > 0) { + state.activeTabUid = last(state.tabs).uid; + } else { + state.activeTabUid = null; + } + } + }, + closeAllWorkspaceTabs: (state, action) => { + const workspaceUid = action.payload?.workspaceUid; + + if (workspaceUid) { + // Close non-permanent tabs for specific workspace + state.tabs = filter( + state.tabs, + (t) => t.workspaceUid !== workspaceUid || t.permanent + ); + } else { + // Close all non-permanent tabs + state.tabs = filter(state.tabs, (t) => t.permanent); + } + + // If active tab was closed, activate another one + const activeTabExists = find(state.tabs, (t) => t.uid === state.activeTabUid); + if (!activeTabExists) { + state.activeTabUid = state.tabs.length > 0 ? last(state.tabs).uid : null; + } + }, + reorderWorkspaceTabs: (state, action) => { + const { sourceUid, targetUid } = action.payload; + const tabs = state.tabs; + + const sourceIdx = tabs.findIndex((t) => t.uid === sourceUid); + const targetIdx = tabs.findIndex((t) => t.uid === targetUid); + + // Don't reorder permanent tabs + const sourceTab = tabs[sourceIdx]; + if (sourceTab?.permanent) { + return; + } + + if (sourceIdx < 0 || targetIdx < 0 || sourceIdx === targetIdx) { + return; + } + + const [moved] = tabs.splice(sourceIdx, 1); + tabs.splice(targetIdx, 0, moved); + + state.tabs = tabs; + }, + initializeWorkspaceTabs: (state, action) => { + const { workspaceUid, permanentTabs } = action.payload; + + // Check if permanent tabs already exist for this workspace + const existingPermanentTabs = state.tabs.filter( + (t) => t.workspaceUid === workspaceUid && t.permanent + ); + + if (existingPermanentTabs.length === 0) { + // Add permanent tabs + permanentTabs.forEach((tab) => { + state.tabs.push({ + uid: `${workspaceUid}-${tab.type}`, + workspaceUid, + type: tab.type, + label: tab.label, + permanent: true + }); + }); + } + + const workspaceActiveTab = state.tabs.find( + (t) => t.uid === state.activeTabUid && t.workspaceUid === workspaceUid + ); + + if (!workspaceActiveTab) { + const workspaceTabs = state.tabs.filter((t) => t.workspaceUid === workspaceUid); + if (workspaceTabs.length > 0) { + state.activeTabUid = workspaceTabs[0].uid; + } + } + }, + setActiveWorkspaceTab: (state, action) => { + const { workspaceUid, type } = action.payload; + let tab = find( + state.tabs, + (t) => t.workspaceUid === workspaceUid && t.type === type + ); + + if (!tab) { + const newTabUid = `${workspaceUid}-${type}`; + const newTab = { + uid: newTabUid, + workspaceUid, + type, + label: type === 'overview' ? 'Overview' : type, + permanent: false + }; + state.tabs.push(newTab); + tab = newTab; + } + + state.activeTabUid = tab.uid; + } + } +}); + +export const { + addWorkspaceTab, + focusWorkspaceTab, + closeWorkspaceTab, + closeWorkspaceTabs, + closeAllWorkspaceTabs, + reorderWorkspaceTabs, + initializeWorkspaceTabs, + setActiveWorkspaceTab +} = workspaceTabsSlice.actions; + +export default workspaceTabsSlice.reducer; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js index 185c2ac94..9a74a08da 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/workspaces/actions.js @@ -12,6 +12,7 @@ import { showHomePage } from '../app'; import { createCollection, openCollection, openMultipleCollections } from '../collections/actions'; import { removeCollection } from '../collections'; import { updateGlobalEnvironments } from '../global-environments'; +import { initializeWorkspaceTabs, setActiveWorkspaceTab } from '../workspaceTabs'; import { normalizePath } from 'utils/common/path'; import toast from 'react-hot-toast'; @@ -253,6 +254,13 @@ export const switchWorkspace = (workspaceUid) => { await loadWorkspaceCollectionsForSwitch(dispatch, workspace); dispatch(showHomePage()); + + const permanentTabs = [ + { type: 'overview', label: 'Overview' }, + { type: 'environments', label: 'Global Environments' } + ]; + dispatch(initializeWorkspaceTabs({ workspaceUid, permanentTabs })); + dispatch(setActiveWorkspaceTab({ workspaceUid, type: 'overview' })); }; };