add: workspace tabs (#6456)

* add: workspace tabs

* fixes

* fixes
This commit is contained in:
naman-bruno
2025-12-18 21:01:58 +05:30
committed by GitHub
parent 052d143d6e
commit a1c4113897
9 changed files with 673 additions and 45 deletions

View File

@@ -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;

View File

@@ -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 <WorkspaceOverview workspace={activeWorkspace} />;
case 'environments':
@@ -244,17 +244,7 @@ const WorkspaceHome = () => {
)}
</div>
<div className="tabs-container">
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab-item ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<WorkspaceTabs workspaceUid={activeWorkspace.uid} />
<div className="tab-content">{renderTabContent()}</div>
</div>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 (
<StyledWrapper className={`flex items-center justify-between tab-container px-2 ${tab.permanent ? 'permanent' : ''}`}>
<div className="flex items-center tab-label">
{TabIcon && (
<span className="tab-icon">
<TabIcon size={14} strokeWidth={1.5} />
</span>
)}
<span className="tab-name" title={tab.label}>
{tab.label}
</span>
</div>
{!tab.permanent && (
<div className="close-icon" onClick={handleCloseClick}>
<IconX size={14} strokeWidth={1.5} />
</div>
)}
</StyledWrapper>
);
};
export default WorkspaceTab;

View File

@@ -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 (
<StyledWrapper className={getRootClassname()}>
<div className="flex items-center pl-2">
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={leftSlide}>
<div className="flex items-center">
<IconChevronLeft size={18} strokeWidth={1.5} />
</div>
</li>
) : null}
</ul>
<div className="tabs-scroll-container" style={{ maxWidth: maxTablistWidth }} ref={scrollContainerRef}>
<ul role="tablist" ref={tabsRef}>
{workspaceTabs.map((tab, index) => (
<li
key={tab.uid}
className={getTabClassname(tab, index)}
onClick={() => handleClick(tab)}
>
<WorkspaceTab
tab={tab}
isActive={tab.uid === activeTabUid}
hasOverflow={tabOverflowStates[tab.uid]}
setHasOverflow={createSetHasOverflow(tab.uid)}
/>
</li>
))}
</ul>
</div>
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={rightSlide}>
<div className="flex items-center">
<IconChevronRight size={18} strokeWidth={1.5} />
</div>
</li>
) : null}
</ul>
</div>
</StyledWrapper>
);
};
export default WorkspaceTabs;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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' }));
};
};