diff --git a/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/StyledWrapper.js new file mode 100644 index 000000000..cd9034d02 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/index.js b/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/index.js new file mode 100644 index 000000000..202cb1c48 --- /dev/null +++ b/packages/bruno-app/src/components/RequestTabPanel/CollapsedPanelIndicator/index.js @@ -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 ( + +
+ + {label} +
+
+ ); +}; + +export default CollapsedPanelIndicator; diff --git a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js index 7bf6bb5f8..13849f48e 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js @@ -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}; } + } } diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index a651bb32c..d8eaa4515 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -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
; @@ -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 ( -
+
{renderQueryUrl()}
-
-
+
+ {requestPaneCollapsed ? ( + + ) : ( +
+
+ {renderRequestPane()} +
+
+ )} + + {!requestPaneCollapsed && !responsePaneCollapsed && (
{ + e.preventDefault(); + resetPaneBoundaries(); + }} + onMouseDown={startDragging} > - {renderRequestPane()} +
-
+ )} -
{ - e.preventDefault(); - resetPaneBoundaries(); - }} - onMouseDown={handleDragbarMouseDown} - > -
-
- -
- {renderResponsePane()} -
+ {responsePaneCollapsed ? ( + + ) : ( +
+ {renderResponsePane()} +
+ )}
{item.type === 'graphql-request' ? ( @@ -415,6 +540,17 @@ const RequestTabPanel = () => {
) : null} + {dragging ? ( +
+ ) : null} ); diff --git a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js index 22d05d15d..02d18d40f 100644 --- a/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js +++ b/packages/bruno-app/src/hooks/useTabPaneBoundaries/index.js @@ -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 diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index 57d65b016..af50e3bc1 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -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,