collapsible request/response split in request tab (#7566)

This commit is contained in:
gopu-bruno
2026-04-30 11:54:41 +05:30
committed by GitHub
parent 4d6e342fdb
commit 8269d51df4
6 changed files with 497 additions and 45 deletions

View File

@@ -0,0 +1,127 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
background: ${(props) => props.theme.bg};
transition: background-color 0.15s ease;
user-select: none;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&:focus-visible {
outline: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
outline-offset: -1px;
}
.panel-label {
font-size: 10px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
text-transform: uppercase;
letter-spacing: 0.5px;
}
.expand-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
flex-shrink: 0;
}
&:hover .expand-icon {
opacity: 1;
color: ${(props) => props.theme.text};
}
&:hover .panel-label {
color: ${(props) => props.theme.text};
}
/* Horizontal layout - panels stacked on left or right */
&.horizontal {
width: 32px;
min-width: 32px;
height: 100%;
cursor: pointer;
border-left: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
border-right: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
width: 8px;
height: 100%;
cursor: col-resize;
z-index: 2;
}
.indicator-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-90deg);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
}
&.request {
border-left: none;
&::before { right: -4px; }
}
&.response {
border-right: none;
&::before { left: -4px; }
}
}
/* Vertical layout - panels stacked on top or bottom */
&.vertical {
width: 100%;
height: 28px;
min-height: 28px;
flex-direction: row;
cursor: pointer;
border-top: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
width: 100%;
height: 8px;
cursor: row-resize;
z-index: 2;
}
.indicator-content {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
&.request {
border-top: none;
&::before { bottom: -4px; }
}
&.response {
border-bottom: none;
&::before { top: -4px; }
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,80 @@
import React, { useRef, useCallback } from 'react';
import { IconChevronDown, IconChevronUp } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const CollapsedPanelIndicator = ({
panelType, // 'request' or 'response'
isVertical,
onExpand,
onDragStart,
dragThresholdPx
}) => {
const dragThresholdSq = dragThresholdPx * dragThresholdPx; // to use in distance check
const label = panelType === 'request' ? 'Request' : 'Response';
const ChevronIcon = panelType === 'request' ? IconChevronDown : IconChevronUp;
const pointerDownRef = useRef(null);
const handlePointerDown = useCallback((e) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.style.cursor = isVertical ? 'row-resize' : 'col-resize';
pointerDownRef.current = { x: e.clientX, y: e.clientY };
}, [isVertical]);
const handlePointerMove = useCallback((e) => {
if (!pointerDownRef.current) return;
const dx = e.clientX - pointerDownRef.current.x;
const dy = e.clientY - pointerDownRef.current.y;
if (dx * dx + dy * dy > dragThresholdSq) {
pointerDownRef.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
onDragStart?.(e);
}
}, [onDragStart, dragThresholdSq]);
const handlePointerUp = useCallback((e) => {
if (!pointerDownRef.current) return;
pointerDownRef.current = null;
e.currentTarget.style.cursor = '';
e.currentTarget.releasePointerCapture(e.pointerId);
onExpand();
}, [onExpand]);
const handlePointerCancel = useCallback((e) => {
if (!pointerDownRef.current) return;
pointerDownRef.current = null;
e.currentTarget.style.cursor = '';
e.currentTarget.releasePointerCapture(e.pointerId);
}, []);
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onExpand();
}
}, [onExpand]);
return (
<StyledWrapper
className={`collapsed-panel-indicator ${isVertical ? 'vertical' : 'horizontal'} ${panelType}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
aria-label={`Expand ${label} pane`}
title={`Click to expand ${label} pane, or drag to resize`}
>
<div className="indicator-content">
<ChevronIcon size={14} strokeWidth={2} className="expand-icon" />
<span className="panel-label">{label}</span>
</div>
</StyledWrapper>
);
};
export default CollapsedPanelIndicator;

View File

@@ -17,56 +17,89 @@ const StyledWrapper = styled.div`
min-width: 0;
}
.main {
padding-bottom: 1rem;
}
&.request-collapsed .query-url-wrapper,
&.response-collapsed .query-url-wrapper {
padding-bottom: 0;
}
&.request-collapsed .main,
&.response-collapsed .main {
padding-bottom: 0;
}
&.request-collapsed .response-pane,
&.response-collapsed .request-pane {
padding-top: 1rem;
}
div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
width: 12px;
min-width: 12px;
padding: 0;
cursor: col-resize;
background: transparent;
position: relative;
z-index: 1;
div.dragbar-handle {
display: flex;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
pointer-events: none;
}
&:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
&.vertical-layout {
.request-pane {
padding-bottom: 0.5rem;
}
.response-pane {
padding-top: 0.5rem;
}
&.request-collapsed .response-pane {
padding-top: 0;
}
&.response-collapsed .request-pane {
padding-bottom: 0;
}
div.dragbar-wrapper {
width: 100%;
height: 10px;
height: 12px;
cursor: row-resize;
padding: 0 1rem;
position: relative;
z-index: 1;
div.dragbar-handle {
width: 100%;
height: 1px;
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
pointer-events: none;
}
&:hover div.dragbar-handle {
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
}

View File

@@ -41,11 +41,14 @@ import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
import OpenAPISyncTab from 'components/OpenAPISyncTab';
import OpenAPISpecTab from 'components/OpenAPISpecTab';
import CollapsedPanelIndicator from './CollapsedPanelIndicator';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 490;
const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const COLLAPSE_EDGE_THRESHOLD = 80;
const EXPAND_EDGE_THRESHOLD = 100;
const RequestTabPanel = () => {
const dispatch = useDispatch();
@@ -94,7 +97,19 @@ const RequestTabPanel = () => {
const [dragging, setDragging] = useState(false);
const draggingRef = useRef(false);
const { left: leftPaneWidth, top: topPaneHeight, reset: resetPaneBoundaries, setTop: setTopPaneHeight, setLeft: setLeftPaneWidth } = useTabPaneBoundaries(activeTabUid);
const {
left: leftPaneWidth,
top: topPaneHeight,
reset: resetPaneBoundaries,
setTop: setTopPaneHeight,
setLeft: setLeftPaneWidth,
requestPaneCollapsed,
responsePaneCollapsed,
collapseRequest,
expandRequest,
collapseResponse,
expandResponse
} = useTabPaneBoundaries(activeTabUid);
const previousTopPaneHeight = useRef(null); // Store height before devtools opens
// Not a recommended pattern here to have the child component
@@ -122,6 +137,27 @@ const RequestTabPanel = () => {
}
}, [dispatch, activeTabUid, showGqlDocs]);
// Refs for panel collapse/expand functions and current collapsed state
const collapseRequestRef = useRef(collapseRequest);
const collapseResponseRef = useRef(collapseResponse);
const expandRequestRef = useRef(expandRequest);
const expandResponseRef = useRef(expandResponse);
const requestPaneCollapsedRef = useRef(requestPaneCollapsed);
const responsePaneCollapsedRef = useRef(responsePaneCollapsed);
useEffect(() => {
collapseRequestRef.current = collapseRequest;
collapseResponseRef.current = collapseResponse;
expandRequestRef.current = expandRequest;
expandResponseRef.current = expandResponse;
requestPaneCollapsedRef.current = requestPaneCollapsed;
responsePaneCollapsedRef.current = responsePaneCollapsed;
}, [collapseRequest, collapseResponse, expandRequest, expandResponse, requestPaneCollapsed, responsePaneCollapsed]);
const stopDragging = useCallback(() => {
draggingRef.current = false;
setDragging(false);
}, []);
const handleMouseMove = useCallback((e) => {
if (!draggingRef.current || !mainSectionRef.current) return;
@@ -131,13 +167,47 @@ const RequestTabPanel = () => {
if (isVerticalLayoutRef.current) {
const newHeight = e.clientY - mainRect.top;
const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT;
// Clamp to bounds instead of returning early
const distanceFromBottom = mainRect.bottom - e.clientY;
if (newHeight < COLLAPSE_EDGE_THRESHOLD) {
if (!requestPaneCollapsedRef.current) collapseRequestRef.current();
return;
}
if (distanceFromBottom < COLLAPSE_EDGE_THRESHOLD) {
if (!responsePaneCollapsedRef.current) collapseResponseRef.current();
return;
}
if (requestPaneCollapsedRef.current && newHeight < EXPAND_EDGE_THRESHOLD) return;
if (responsePaneCollapsedRef.current && distanceFromBottom < EXPAND_EDGE_THRESHOLD) return;
if (requestPaneCollapsedRef.current) expandRequestRef.current();
if (responsePaneCollapsedRef.current) expandResponseRef.current();
const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight));
setTopPaneHeight(clampedHeight);
} else {
const newWidth = e.clientX - mainRect.left;
const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH;
// Clamp to bounds instead of returning early
const distanceFromRight = mainRect.right - e.clientX;
if (newWidth < COLLAPSE_EDGE_THRESHOLD) {
if (!requestPaneCollapsedRef.current) collapseRequestRef.current();
return;
}
if (distanceFromRight < COLLAPSE_EDGE_THRESHOLD) {
if (!responsePaneCollapsedRef.current) collapseResponseRef.current();
return;
}
if (requestPaneCollapsedRef.current && newWidth < EXPAND_EDGE_THRESHOLD) return;
if (responsePaneCollapsedRef.current && distanceFromRight < EXPAND_EDGE_THRESHOLD) return;
if (requestPaneCollapsedRef.current) expandRequestRef.current();
if (responsePaneCollapsedRef.current) expandResponseRef.current();
const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth));
setLeftPaneWidth(clampedWidth);
}
@@ -146,17 +216,45 @@ const RequestTabPanel = () => {
const handleMouseUp = useCallback((e) => {
if (draggingRef.current) {
e.preventDefault();
draggingRef.current = false;
setDragging(false);
stopDragging();
}
}, []);
}, [stopDragging]);
const handleDragbarMouseDown = useCallback((e) => {
const startDragging = useCallback((e) => {
e.preventDefault();
draggingRef.current = true;
setDragging(true);
}, []);
const applyPointerResize = useCallback((e) => {
if (!mainSectionRef.current) return;
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayoutRef.current) {
const newHeight = e.clientY - mainRect.top;
const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT;
const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight));
setTopPaneHeight(clampedHeight);
} else {
const newWidth = e.clientX - mainRect.left;
const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH;
const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth));
setLeftPaneWidth(clampedWidth);
}
}, [setTopPaneHeight, setLeftPaneWidth]);
const handleRequestIndicatorDragStart = useCallback((e) => {
expandRequest();
applyPointerResize(e);
startDragging(e);
}, [expandRequest, applyPointerResize, startDragging]);
const handleResponseIndicatorDragStart = useCallback((e) => {
expandResponse();
applyPointerResize(e);
startDragging(e);
}, [expandResponse, applyPointerResize, startDragging]);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
@@ -169,6 +267,7 @@ const RequestTabPanel = () => {
useEffect(() => {
if (!isVerticalLayout) return;
if (responsePaneCollapsed) return;
if (isConsoleOpen) {
// Store current height before reducing
@@ -187,7 +286,7 @@ const RequestTabPanel = () => {
previousTopPaneHeight.current = null;
}
}
}, [isConsoleOpen, isVerticalLayout]);
}, [isConsoleOpen, isVerticalLayout, responsePaneCollapsed]);
if (typeof window == 'undefined') {
return <div></div>;
@@ -360,50 +459,76 @@ const RequestTabPanel = () => {
}
};
const requestPaneStyle = isVerticalLayout
? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
}
: {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
};
const getRequestPaneStyle = () => {
if (responsePaneCollapsed) {
return isVerticalLayout
? { flex: 1, width: '100%' }
: { flex: 1 };
}
return isVerticalLayout
? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
}
: {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
};
};
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<StyledWrapper
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
isVerticalLayout ? 'vertical-layout' : ''
}`}
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''
} ${requestPaneCollapsed ? 'request-collapsed' : ''} ${responsePaneCollapsed ? 'response-collapsed' : ''}`}
>
<div className="pt-3 pb-3 px-4">
<div className="query-url-wrapper pt-3 pb-4 px-4">
{renderQueryUrl()}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane" data-testid="request-pane">
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow relative overflow-auto`}>
{requestPaneCollapsed ? (
<CollapsedPanelIndicator
panelType="request"
isVertical={isVerticalLayout}
onExpand={expandRequest}
onDragStart={handleRequestIndicatorDragStart}
dragThresholdPx={isVerticalLayout ? MIN_TOP_PANE_HEIGHT / 2 : MIN_LEFT_PANE_WIDTH / 2}
/>
) : (
<section className="request-pane" data-testid="request-pane" style={getRequestPaneStyle()}>
<div className="px-4 h-full">
{renderRequestPane()}
</div>
</section>
)}
{!requestPaneCollapsed && !responsePaneCollapsed && (
<div
className="px-4 h-full"
style={requestPaneStyle}
className="dragbar-wrapper"
onDoubleClick={(e) => {
e.preventDefault();
resetPaneBoundaries();
}}
onMouseDown={startDragging}
>
{renderRequestPane()}
<div className="dragbar-handle" />
</div>
</section>
)}
<div
className="dragbar-wrapper"
onDoubleClick={(e) => {
e.preventDefault();
resetPaneBoundaries();
}}
onMouseDown={handleDragbarMouseDown}
>
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane">
{renderResponsePane()}
</section>
{responsePaneCollapsed ? (
<CollapsedPanelIndicator
panelType="response"
isVertical={isVerticalLayout}
onExpand={expandResponse}
onDragStart={handleResponseIndicatorDragStart}
dragThresholdPx={isVerticalLayout ? MIN_BOTTOM_PANE_HEIGHT / 2 : MIN_RIGHT_PANE_WIDTH / 2}
/>
) : (
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane" style={requestPaneCollapsed ? { flex: 1 } : undefined}>
{renderResponsePane()}
</section>
)}
</section>
{item.type === 'graphql-request' ? (
@@ -415,6 +540,17 @@ const RequestTabPanel = () => {
</DocExplorer>
</div>
) : null}
{dragging ? (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
cursor: isVerticalLayout ? 'row-resize' : 'col-resize',
userSelect: 'none'
}}
/>
) : null}
</StyledWrapper>
</ScopedPersistenceProvider>
);

View File

@@ -1,5 +1,12 @@
import find from 'lodash/find';
import { updateRequestPaneTabHeight, updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import {
updateRequestPaneTabHeight,
updateRequestPaneTabWidth,
collapseRequestPane,
collapseResponsePane,
expandRequestPane,
expandResponsePane
} from 'providers/ReduxStore/slices/tabs';
import { useDispatch, useSelector } from 'react-redux';
const MIN_TOP_PANE_HEIGHT = 380;
@@ -13,11 +20,16 @@ export function useTabPaneBoundaries(activeTabUid) {
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const left = focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / DEFAULT_PANE_WIDTH_DIVISOR;
const top = focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT;
const requestPaneCollapsed = focusedTab?.requestPaneCollapsed || false;
const responsePaneCollapsed = focusedTab?.responsePaneCollapsed || false;
const dispatch = useDispatch();
return {
left,
top,
requestPaneCollapsed,
responsePaneCollapsed,
setLeft(value) {
dispatch(updateRequestPaneTabWidth({
uid: activeTabUid,
@@ -30,7 +42,21 @@ export function useTabPaneBoundaries(activeTabUid) {
requestPaneHeight: value
}));
},
collapseRequest() {
dispatch(collapseRequestPane({ uid: activeTabUid }));
},
expandRequest() {
dispatch(expandRequestPane({ uid: activeTabUid }));
},
collapseResponse() {
dispatch(collapseResponsePane({ uid: activeTabUid }));
},
expandResponse() {
dispatch(expandResponsePane({ uid: activeTabUid }));
},
reset() {
dispatch(expandRequestPane({ uid: activeTabUid }));
dispatch(expandResponsePane({ uid: activeTabUid }));
dispatch(updateRequestPaneTabHeight({
uid: activeTabUid,
requestPaneHeight: MIN_TOP_PANE_HEIGHT

View File

@@ -64,6 +64,11 @@ export const tabsSlice = createSlice({
uid,
collectionUid,
requestPaneWidth: null,
requestPaneHeight: null,
requestPaneCollapsed: false,
responsePaneCollapsed: false,
requestPaneWidthBeforeCollapse: null,
requestPaneHeightBeforeCollapse: null,
requestPaneTab: requestPaneTab || defaultRequestPaneTab,
responsePaneTab: 'response',
responseFormat: null,
@@ -87,6 +92,11 @@ export const tabsSlice = createSlice({
uid,
collectionUid,
requestPaneWidth: null,
requestPaneHeight: null,
requestPaneCollapsed: false,
responsePaneCollapsed: false,
requestPaneWidthBeforeCollapse: null,
requestPaneHeightBeforeCollapse: null,
requestPaneTab: requestPaneTab || defaultRequestPaneTab,
responsePaneTab: 'response',
responseFormat: null,
@@ -316,6 +326,42 @@ export const tabsSlice = createSlice({
console.error('Tab not found!');
}
},
collapseRequestPane: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
if (tab) {
tab.requestPaneCollapsed = true;
tab.responsePaneCollapsed = false;
tab.requestPaneWidthBeforeCollapse = tab.requestPaneWidth;
tab.requestPaneHeightBeforeCollapse = tab.requestPaneHeight;
}
},
collapseResponsePane: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
if (tab) {
tab.responsePaneCollapsed = true;
tab.requestPaneCollapsed = false;
}
},
expandRequestPane: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
if (tab) {
tab.requestPaneCollapsed = false;
if (tab.requestPaneWidthBeforeCollapse != null) {
tab.requestPaneWidth = tab.requestPaneWidthBeforeCollapse;
}
if (tab.requestPaneHeightBeforeCollapse != null) {
tab.requestPaneHeight = tab.requestPaneHeightBeforeCollapse;
}
tab.requestPaneWidthBeforeCollapse = null;
tab.requestPaneHeightBeforeCollapse = null;
}
},
expandResponsePane: (state, action) => {
const tab = find(state.tabs, (t) => t.uid === action.payload.uid);
if (tab) {
tab.responsePaneCollapsed = false;
}
},
reorderTabs: (state, action) => {
const { direction, sourceUid, targetUid } = action.payload;
const tabs = state.tabs;
@@ -383,6 +429,10 @@ export const {
closeTabs,
closeAllCollectionTabs,
makeTabPermanent,
collapseRequestPane,
collapseResponsePane,
expandRequestPane,
expandResponsePane,
reorderTabs,
reopenLastClosedTab,
updateQueryBuilderOpen,