diff --git a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js index 0f768a698..698bb90ae 100644 --- a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js +++ b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js @@ -4,11 +4,8 @@ const StyledWrapper = styled.div` display: flex; flex-direction: column; height: 100%; + width: 100%; background: ${(props) => props.theme.console.contentBg}; - border-left: 1px solid ${(props) => props.theme.console.border}; - min-width: 400px; - max-width: 600px; - width: 40%; overflow: hidden; .panel-header { diff --git a/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js index f2149f50d..5a16352a8 100644 --- a/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js +++ b/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js @@ -144,6 +144,41 @@ const StyledWrapper = styled.div` gap: 4px; } + .details-panel-wrapper { + position: relative; + flex-shrink: 0; + height: 100%; + display: flex; + } + + div.details-drag-handle { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + cursor: col-resize; + background-color: transparent; + width: 6px; + position: absolute; + left: -3px; + top: 0; + z-index: 10; + transition: opacity 0.2s ease; + + div.drag-request-border { + width: 1px; + height: 100%; + border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.border}; + } + + &:hover div.drag-request-border { + width: 1px; + height: 100%; + border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.activeBorder}; + } + } + + .action-controls { display: flex; align-items: center; diff --git a/packages/bruno-app/src/components/Devtools/Console/index.js b/packages/bruno-app/src/components/Devtools/Console/index.js index fbf4776d5..f15bf7a7f 100644 --- a/packages/bruno-app/src/components/Devtools/Console/index.js +++ b/packages/bruno-app/src/components/Devtools/Console/index.js @@ -23,7 +23,8 @@ import { setActiveTab, clearDebugErrors, updateNetworkFilter, - toggleAllNetworkFilters + toggleAllNetworkFilters, + updateRequestDetailsPanelWidth } from 'providers/ReduxStore/slices/logs'; import NetworkTab from './NetworkTab'; @@ -33,6 +34,10 @@ import RequestDetailsPanel from './RequestDetailsPanel'; import ErrorDetailsPanel from './ErrorDetailsPanel'; import Performance from '../Performance'; import StyledWrapper from './StyledWrapper'; +import { useResizablePanel } from 'hooks/useResizablePanel'; + +const MIN_DETAILS_PANEL_WIDTH = 280; +const MAX_DETAILS_PANEL_WIDTH = 800; const LogIcon = ({ type }) => { const iconProps = { size: 16, strokeWidth: 1.5 }; @@ -381,8 +386,17 @@ const Console = () => { const dispatch = useDispatch(); const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector((state) => state.logs); const collections = useSelector((state) => state.collections.collections); + const savedDetailsPanelWidth = useSelector((state) => state.logs.requestDetailsPanelWidth); const consoleRef = useRef(null); + const { width: detailsPanelWidth, handleDragStart: handleDetailsPanelDragStart } = useResizablePanel({ + initialWidth: savedDetailsPanelWidth, + minWidth: MIN_DETAILS_PANEL_WIDTH, + maxWidth: MAX_DETAILS_PANEL_WIDTH, + direction: 'right', + onResizeEnd: (newWidth) => dispatch(updateRequestDetailsPanelWidth({ requestDetailsPanelWidth: newWidth })) + }); + const logCounts = logs.reduce((counts, log) => { counts[log.type] = (counts[log.type] || 0) + 1; return counts; @@ -614,7 +628,16 @@ const Console = () => {
{renderTabContent()}
- +
+
+
+
+ +
) : activeTab === 'debug' && selectedError ? (
diff --git a/packages/bruno-app/src/hooks/useResizablePanel/index.js b/packages/bruno-app/src/hooks/useResizablePanel/index.js new file mode 100644 index 000000000..acb988ad2 --- /dev/null +++ b/packages/bruno-app/src/hooks/useResizablePanel/index.js @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Drag-to-resize behavior for a side panel. + * + * @param {object} options + * @param {number} options.initialWidth - Starting width in px + * @param {number} options.minWidth - Minimum allowed width in px + * @param {number} options.maxWidth - Maximum allowed width in px + * @param {'left' | 'right'} options.direction - Panel side. 'right' means dragging + * left expands the panel; 'left' means dragging right expands it. + * @param {function} [options.onResizeEnd] - Called with the final width on mouseup + * + * @returns {{ width: number, handleDragStart: function }} + */ +export function useResizablePanel({ + initialWidth, + minWidth, + maxWidth, + direction = 'left', + onResizeEnd +}) { + const [width, setWidth] = useState(initialWidth); + + const isDragging = useRef(false); + const dragStartX = useRef(0); + const dragStartWidth = useRef(0); + const currentWidth = useRef(initialWidth); + + const clamp = (w) => Math.min(maxWidth, Math.max(minWidth, w)); + + const handleDragStart = (e) => { + isDragging.current = true; + dragStartX.current = e.clientX; + dragStartWidth.current = currentWidth.current; + e.preventDefault(); + }; + + const handleMouseMove = (e) => { + if (!isDragging.current) return; + const delta + = direction === 'right' + ? dragStartX.current - e.clientX // drag left = expand + : e.clientX - dragStartX.current; // drag right = expand + const newWidth = clamp(dragStartWidth.current + delta); + currentWidth.current = newWidth; + setWidth(newWidth); + }; + + const handleMouseUp = () => { + if (isDragging.current) { + if (onResizeEnd) onResizeEnd(currentWidth.current); + } + isDragging.current = false; + }; + + useEffect(() => { + // Note: tying the events to the document instead of the parent to avoid fast movement + // from breaking the flow state of dragging + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + return { width, handleDragStart }; +} diff --git a/packages/bruno-app/src/hooks/useResizablePanel/index.spec.js b/packages/bruno-app/src/hooks/useResizablePanel/index.spec.js new file mode 100644 index 000000000..b6a3887bd --- /dev/null +++ b/packages/bruno-app/src/hooks/useResizablePanel/index.spec.js @@ -0,0 +1,192 @@ +const { describe, it, expect, jest } = require('@jest/globals'); +import { renderHook, act } from '@testing-library/react'; +import { useResizablePanel } from './index'; + +const MIN_WIDTH = 280; +const MAX_WIDTH = 800; +const INITIAL_WIDTH = 400; + +const renderResizablePanel = ({ + initialWidth = INITIAL_WIDTH, + direction = 'right', + onResizeEnd = jest.fn() +} = {}) => { + const result = renderHook( + ({ initialWidth, direction, onResizeEnd }) => + useResizablePanel({ + initialWidth, + minWidth: MIN_WIDTH, + maxWidth: MAX_WIDTH, + direction, + onResizeEnd + }), + { initialProps: { initialWidth, direction, onResizeEnd } } + ); + return { ...result, onResizeEnd }; +}; + +const fireMouse = (type, clientX) => { + act(() => { + document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true })); + }); +}; + +describe('useResizablePanel', () => { + it('returns the initial width on first render', () => { + const { result } = renderResizablePanel(); + expect(result.current.width).toBe(INITIAL_WIDTH); + }); + + it('handleDragStart does not change width', () => { + const { result } = renderResizablePanel(); + + act(() => { + result.current.handleDragStart({ + clientX: 500, + preventDefault: jest.fn() + }); + }); + + expect(result.current.width).toBe(INITIAL_WIDTH); + }); + + it('mousemove without an active drag is a no-op', () => { + const { result } = renderResizablePanel(); + + fireMouse('mousemove', 300); + + expect(result.current.width).toBe(INITIAL_WIDTH); + }); + + it('mouseup without an active drag does not call onResizeEnd', () => { + const { result, onResizeEnd } = renderResizablePanel(); + + fireMouse('mouseup', 300); + + expect(onResizeEnd).not.toHaveBeenCalled(); + }); + + describe('direction: right', () => { + it('dragging left increases width', () => { + const { result } = renderResizablePanel({ direction: 'right' }); + + act(() => { + result.current.handleDragStart({ + clientX: 500, + preventDefault: jest.fn() + }); + }); + + fireMouse('mousemove', 400); // moved 100px left → delta = +100 + expect(result.current.width).toBe(INITIAL_WIDTH + 100); + }); + + it('dragging right decreases width', () => { + const { result } = renderResizablePanel({ direction: 'right' }); + + act(() => { + result.current.handleDragStart({ + clientX: 500, + preventDefault: jest.fn() + }); + }); + + fireMouse('mousemove', 600); // moved 100px right → delta = -100 + expect(result.current.width).toBe(INITIAL_WIDTH - 100); + }); + }); + + describe('direction: left', () => { + it('dragging right increases width', () => { + const { result } = renderResizablePanel({ direction: 'left' }); + + act(() => { + result.current.handleDragStart({ + clientX: 500, + preventDefault: jest.fn() + }); + }); + + fireMouse('mousemove', 600); // moved 100px right → delta = +100 + expect(result.current.width).toBe(INITIAL_WIDTH + 100); + }); + + it('dragging left decreases width', () => { + const { result } = renderResizablePanel({ direction: 'left' }); + + act(() => { + result.current.handleDragStart({ + clientX: 500, + preventDefault: jest.fn() + }); + }); + + fireMouse('mousemove', 400); // moved 100px left → delta = -100 + expect(result.current.width).toBe(INITIAL_WIDTH - 100); + }); + }); + + it('clamps width to minWidth', () => { + const { result } = renderResizablePanel({ direction: 'right' }); + + act(() => { + result.current.handleDragStart({ + clientX: 500, + preventDefault: jest.fn() + }); + }); + + fireMouse('mousemove', 1000); // large rightward move → would go below minWidth + expect(result.current.width).toBe(MIN_WIDTH); + }); + + it('clamps width to maxWidth', () => { + const { result } = renderResizablePanel({ direction: 'right' }); + + act(() => { + result.current.handleDragStart({ + clientX: 500, + preventDefault: jest.fn() + }); + }); + + fireMouse('mousemove', -1000); // large leftward move → would exceed maxWidth + expect(result.current.width).toBe(MAX_WIDTH); + }); + + it('mouseup calls onResizeEnd with the final width', () => { + const { result, onResizeEnd } = renderResizablePanel({ + direction: 'right' + }); + + act(() => { + result.current.handleDragStart({ + clientX: 500, + preventDefault: jest.fn() + }); + }); + + fireMouse('mousemove', 400); + fireMouse('mouseup', 400); + + expect(onResizeEnd).toHaveBeenCalledTimes(1); + expect(onResizeEnd).toHaveBeenCalledWith(INITIAL_WIDTH + 100); + }); + + it('stops updating width after mouseup', () => { + const { result } = renderResizablePanel({ direction: 'right' }); + + act(() => { + result.current.handleDragStart({ + clientX: 500, + preventDefault: jest.fn() + }); + }); + + fireMouse('mousemove', 400); + fireMouse('mouseup', 400); + fireMouse('mousemove', 200); // further move after release — should be ignored + + expect(result.current.width).toBe(INITIAL_WIDTH + 100); + }); +}); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/logs.js b/packages/bruno-app/src/providers/ReduxStore/slices/logs.js index c55cdef50..3f88ba3d2 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/logs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/logs.js @@ -33,7 +33,8 @@ const initialState = { selectedRequest: null, selectedError: null, maxLogs: 1000, - maxDebugErrors: 500 + maxDebugErrors: 500, + requestDetailsPanelWidth: 400 }; export const logsSlice = createSlice({ @@ -127,6 +128,9 @@ export const logsSlice = createSlice({ }, clearSelectedError: (state) => { state.selectedError = null; + }, + updateRequestDetailsPanelWidth: (state, action) => { + state.requestDetailsPanelWidth = action.payload.requestDetailsPanelWidth; } } }); @@ -146,7 +150,8 @@ export const { setSelectedRequest, clearSelectedRequest, setSelectedError, - clearSelectedError + clearSelectedError, + updateRequestDetailsPanelWidth } = logsSlice.actions; export default logsSlice.reducer;