diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js index 8142d6520..a21332d45 100644 --- a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js @@ -37,7 +37,7 @@ const StyledWrapper = styled.div` overflow: hidden; display: flex; flex-direction: column; - min-height: 0; /* Important for proper flex behavior */ + min-height: 0; } .network-empty { @@ -68,47 +68,40 @@ const StyledWrapper = styled.div` flex-direction: column; height: 100%; overflow: hidden; - min-height: 0; /* Important for proper flex behavior */ + min-height: 0; position: relative; - } - .col-separator { - position: absolute; - top: 0; - bottom: 0; - width: 1px; - background: ${(props) => props.theme.console.border}; - pointer-events: none; - z-index: 2; + &.is-resizing { + cursor: col-resize; + user-select: none; + } } .requests-header { display: grid; - padding: 0; + flex-shrink: 0; background: ${(props) => props.theme.console.headerBg}; border-bottom: 1px solid ${(props) => props.theme.console.border}; font-size: 10px; color: ${(props) => props.theme.console.titleColor}; text-transform: uppercase; letter-spacing: 0.5px; - flex-shrink: 0; + + & > * { + min-width: 0; + overflow: hidden; + } .header-cell { display: flex; align-items: center; - justify-content: space-between; gap: 4px; padding: 4px 8px; cursor: pointer; user-select: none; - &:first-child { - padding-left: 16px; - } - - &:last-child { - padding-right: 16px; - } + &:first-child { padding-left: 16px; } + &:last-child { padding-right: 16px; } &:hover { color: ${(props) => props.theme.console.messageColor}; @@ -120,10 +113,7 @@ const StyledWrapper = styled.div` white-space: nowrap; } - svg { - flex-shrink: 0; - } - + svg { flex-shrink: 0; } } } @@ -131,48 +121,70 @@ const StyledWrapper = styled.div` flex: 1; overflow-y: auto; overflow-x: hidden; - min-height: 0; /* Important for proper scrolling */ + min-height: 0; } .request-row { display: grid; - padding: 0; cursor: pointer; transition: background-color 0.1s ease; font-size: ${(props) => props.theme.font.size.sm}; align-items: center; - &:hover { - background: ${(props) => props.theme.console.logHoverBg}; + & > * { + min-width: 0; + overflow: hidden; } + &:hover { background: ${(props) => props.theme.console.logHoverBg}; } + &.selected { background: ${(props) => props.theme.console.logHoverBg}; box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor}; } } - .request-method { - padding: 2px 8px 2px 16px; + .col-separator { + position: absolute; + top: 0; + bottom: 0; + width: 4px; + transform: translateX(-2px); + cursor: col-resize; + z-index: 3; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 1px; + height: 100%; + background: ${(props) => props.theme.sidebar.dragbar.border}; + } + + &:hover::after, + &.resizing::after { + background: ${(props) => props.theme.sidebar.dragbar.activeBorder}; + } } - .request-status { - padding: 2px 8px; - } + .request-method { padding: 2px 8px 2px 16px; } + .request-status { padding: 2px 8px; } .method-badge { display: inline-flex; align-items: center; - justify-content: start; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; - min-width: 45px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - .status-badge { - font-size: ${(props) => props.theme.font.size.sm}; - } + .status-badge { font-size: ${(props) => props.theme.font.size.sm}; } .request-domain { padding: 2px 8px; @@ -196,6 +208,9 @@ const StyledWrapper = styled.div` color: ${(props) => props.theme.console.timestampColor}; font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: ${(props) => props.theme.font.size.xs}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .request-duration { @@ -204,11 +219,12 @@ const StyledWrapper = styled.div` font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: ${(props) => props.theme.font.size.xs}; text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - .text-right { - text-align: right; - } + .text-right { text-align: right; } .request-size { padding: 2px 8px; @@ -216,6 +232,9 @@ const StyledWrapper = styled.div` font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: ${(props) => props.theme.font.size.xs}; text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } `; diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js index 4fae5745c..79d68dd3f 100644 --- a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; +import { usePersistedState } from 'hooks/usePersistedState'; import { useSelector, useDispatch } from 'react-redux'; import { IconNetwork, @@ -8,17 +9,17 @@ import { import { setSelectedRequest } from 'providers/ReduxStore/slices/logs'; +import { useResizableColumns } from 'hooks/useResizableColumns'; import StyledWrapper from './StyledWrapper'; -import { getGridTemplate, getSeparatorPositions, sortRequests } from './utils'; +import { sortRequests } from './utils'; -// TODO: Columns will be resizable in the future, so width can be null (for auto) or a number (for fixed width) const COLUMNS = [ - { key: 'method', label: 'Method', width: 90, align: 'left' }, - { key: 'status', label: 'Status', width: 80, align: 'left' }, - { key: 'domain', label: 'Domain', width: 200, align: 'left' }, - { key: 'path', label: 'Path', width: null, align: 'left' }, - { key: 'time', label: 'Time', width: 100, align: 'left' }, - { key: 'duration', label: 'Duration', width: 120, align: 'right' }, + { key: 'method', label: 'Method', width: 80, align: 'left' }, + { key: 'status', label: 'Status', width: 70, align: 'left' }, + { key: 'domain', label: 'Domain', width: 180, align: 'left' }, + { key: 'path', label: 'Path', width: 300, align: 'left' }, + { key: 'time', label: 'Time', width: 110, align: 'left' }, + { key: 'duration', label: 'Duration', width: 100, align: 'right' }, { key: 'size', label: 'Size', width: 80, align: 'right' } ]; @@ -133,15 +134,27 @@ const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => { const NetworkTab = () => { const dispatch = useDispatch(); - const [sortConfig, setSortConfig] = useState({ key: null, direction: null }); - const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []); - const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []); + const [sortConfig, setSortConfig] = usePersistedState({ key: 'devtools-network-sort', default: { key: null, direction: null } }); + const [savedColWidths, setSavedColWidths] = usePersistedState({ key: 'devtools-network-col-widths', default: null }); + + const { + containerRef, + gridTemplateColumns, + separatorPositions, + resizingIdx, + handleResizeStart + } = useResizableColumns({ + defaultWidths: COLUMNS.map((c) => c.width), + initialWidths: savedColWidths, + minColWidth: 60, + onResizeEnd: setSavedColWidths + }); + const { networkFilters, selectedRequest } = useSelector((state) => state.logs); const collections = useSelector((state) => state.collections.collections); const allRequests = useMemo(() => { const requests = []; - collections.forEach((collection) => { if (collection.timeline) { collection.timeline @@ -155,7 +168,6 @@ const NetworkTab = () => { }); } }); - return requests.sort((a, b) => a.timestamp - b.timestamp); }, [collections]); @@ -166,15 +178,11 @@ const NetworkTab = () => { }); }, [allRequests, networkFilters]); - const handleRequestClick = (request) => { - dispatch(setSelectedRequest(request)); - }; + const handleRequestClick = (request) => dispatch(setSelectedRequest(request)); const handleHeaderClick = (key) => { setSortConfig((prev) => { - // If clicking a different column, start with ascending sort if (prev.key !== key) return { key, direction: 'asc' }; - if (prev.direction === 'asc') return { key, direction: 'desc' }; return { key: null, direction: null }; }); @@ -195,7 +203,7 @@ const NetworkTab = () => { Requests will appear here as you make API calls ) : ( -
+
{COLUMNS.map((col) => (
{ ))}
-
+
{sortedRequests.map((request, index) => ( handleRequestClick(request)} gridTemplateColumns={gridTemplateColumns} /> ))}
- {separatorPositions.map((pos, i) => - pos ? ( -
- ) : null - )} + {separatorPositions.map((left, i) => ( +
handleResizeStart(e, i)} + data-testid={`network-col-separator-${i}`} + /> + ))}
)}
diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.spec.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.spec.js index 5ae619b94..962753bc2 100644 --- a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.spec.js +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.spec.js @@ -29,6 +29,10 @@ const makeRequest = (overrides = {}) => ({ const ALL_FILTERS = { GET: true, POST: true, PUT: true, DELETE: true, PATCH: true, HEAD: true, OPTIONS: true }; +beforeEach(() => { + localStorage.clear(); +}); + const renderNetworkTab = (requests = []) => { const store = configureStore({ reducer: { @@ -55,6 +59,10 @@ const renderNetworkTab = (requests = []) => { ); }; +beforeEach(() => { + localStorage.clear(); +}); + describe('sort state cycle', () => { const requests = [ makeRequest({ itemUid: 'a', method: 'GET' }), @@ -163,6 +171,26 @@ describe('sort results', () => { expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']); }); + it('restores sort config after close and reopen', () => { + const requests = [ + makeRequest({ itemUid: '1', method: 'POST' }), + makeRequest({ itemUid: '2', method: 'GET' }), + makeRequest({ itemUid: '3', method: 'DELETE' }) + ]; + + // First mount — set sort to method descending + const { unmount } = renderNetworkTab(requests); + fireEvent.click(screen.getByTestId('network-header-method')); // asc + fireEvent.click(screen.getByTestId('network-header-method')); // desc + expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']); + unmount(); // simulate closing devtools + + // Second mount — sort should be restored from localStorage + renderNetworkTab(requests); + expect(screen.getByTestId('sort-icon-desc')).toBeInTheDocument(); + expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']); + }); + it('preserves insertion order when sort is cleared', () => { const requests = [ makeRequest({ itemUid: '1', method: 'POST' }), diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js index 507fced80..7d892e1ac 100644 --- a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js +++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js @@ -1,29 +1,3 @@ -export const getGridTemplate = (columns) => - columns.map((c) => (c.width ? `${c.width}px` : '1fr')).join(' '); - -export const getSeparatorPositions = (columns) => { - const n = columns.length; - const positions = new Array(n - 1).fill(null); - - let leftOffset = 0; - for (let i = 0; i < n - 1; i++) { - if (columns[i].width === null) break; - leftOffset += columns[i].width; - positions[i] = { left: leftOffset }; - } - - let rightOffset = 0; - for (let i = n - 1; i > 0; i--) { - if (columns[i].width === null) break; - rightOffset += columns[i].width; - if (positions[i - 1] === null) { - positions[i - 1] = { right: rightOffset }; - } - } - - return positions; -}; - export const getSortValue = (request, key) => { const { request: req, response: res, timestamp } = request.data; switch (key) { diff --git a/packages/bruno-app/src/components/Devtools/Console/index.js b/packages/bruno-app/src/components/Devtools/Console/index.js index f15bf7a7f..6e9cf918c 100644 --- a/packages/bruno-app/src/components/Devtools/Console/index.js +++ b/packages/bruno-app/src/components/Devtools/Console/index.js @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { usePersistedState } from 'hooks/usePersistedState'; import { useSelector, useDispatch } from 'react-redux'; import ReactJson from 'react-json-view'; import { useTheme } from 'providers/Theme'; @@ -23,8 +24,7 @@ import { setActiveTab, clearDebugErrors, updateNetworkFilter, - toggleAllNetworkFilters, - updateRequestDetailsPanelWidth + toggleAllNetworkFilters } from 'providers/ReduxStore/slices/logs'; import NetworkTab from './NetworkTab'; @@ -386,7 +386,7 @@ 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 [savedDetailsPanelWidth, setSavedDetailsPanelWidth] = usePersistedState({ key: 'devtools-details-panel-width', default: 400 }); const consoleRef = useRef(null); const { width: detailsPanelWidth, handleDragStart: handleDetailsPanelDragStart } = useResizablePanel({ @@ -394,7 +394,7 @@ const Console = () => { minWidth: MIN_DETAILS_PANEL_WIDTH, maxWidth: MAX_DETAILS_PANEL_WIDTH, direction: 'right', - onResizeEnd: (newWidth) => dispatch(updateRequestDetailsPanelWidth({ requestDetailsPanelWidth: newWidth })) + onResizeEnd: (newWidth) => setSavedDetailsPanelWidth(newWidth) }); const logCounts = logs.reduce((counts, log) => { diff --git a/packages/bruno-app/src/hooks/useResizableColumns/index.js b/packages/bruno-app/src/hooks/useResizableColumns/index.js new file mode 100644 index 000000000..e30d0aa7a --- /dev/null +++ b/packages/bruno-app/src/hooks/useResizableColumns/index.js @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +/** + * Drag-to-resize behavior for a multi-column grid. + * + * Columns always sum to the container width (no horizontal scroll). + * Dragging a separator adjusts only the two adjacent columns (zero-sum). + * Either column hitting minColWidth causes a hard stop. + * + * @param {object} options + * @param {number[]} options.defaultWidths - Default px width for each column (used as proportions) + * @param {number[]|null} [options.initialWidths] - Persisted widths to restore; falls back to defaultWidths + * @param {number} [options.minColWidth] - Minimum column width in px (default: 60) + * @param {function} [options.onResizeEnd] - Called with final colWidths array after a drag ends + * + * @returns {{ + * containerRef: React.RefObject, + * colWidths: number[] | null, + * gridTemplateColumns: string, + * separatorPositions: number[], + * resizingIdx: number | null, + * handleResizeStart: (e: MouseEvent, separatorIdx: number) => void + * }} + */ + +const getGridTemplate = (widths) => widths.map((w) => `${w}px`).join(' '); + +const getSeparatorPositions = (widths) => { + const positions = []; + let left = 0; + for (let i = 0; i < widths.length - 1; i++) { + left += widths[i]; + positions.push(left); + } + return positions; +}; + +const scaleWidthsToTotal = (widths, targetTotal, minColWidth) => { + // When the container is too narrow to satisfy minColWidth for every column, + // fall back to equal distribution so the total stays exact (columns clip instead of overflow). + if (targetTotal < minColWidth * widths.length) { + const each = Math.floor(targetTotal / widths.length); + const last = targetTotal - each * (widths.length - 1); + return [...Array(widths.length - 1).fill(each), last]; + } + + const currentTotal = widths.reduce((s, w) => s + w, 0); + const factor = targetTotal / currentTotal; + const next = widths.slice(0, -1).map((w) => Math.max(minColWidth, Math.round(w * factor))); + const last = Math.max(minColWidth, targetTotal - next.reduce((s, w) => s + w, 0)); + return [...next, last]; +}; + +export function useResizableColumns({ defaultWidths, initialWidths = null, minColWidth = 60, onResizeEnd = null }) { + const [colWidths, setColWidths] = useState(null); + const [resizingIdx, setResizingIdx] = useState(null); + const dragCleanupRef = useRef(null); + const observerRef = useRef(null); + + useEffect(() => { + return () => { + dragCleanupRef.current?.(); + observerRef.current?.disconnect(); + }; + }, []); + + const gridTemplateColumns = useMemo( + () => colWidths ? getGridTemplate(colWidths) : `repeat(${defaultWidths.length}, 1fr)`, + [colWidths, defaultWidths.length] + ); + + const separatorPositions = useMemo( + () => colWidths ? getSeparatorPositions(colWidths) : [], + [colWidths] + ); + + // Callback ref: attaches the ResizeObserver whenever the element mounts, + // even if it renders conditionally after the initial mount. + const containerRef = useCallback((node) => { + observerRef.current?.disconnect(); + observerRef.current = null; + + if (!node) return; + + const observer = new ResizeObserver((entries) => { + const newWidth = Math.floor(entries[0].contentRect.width); + if (!newWidth) return; + + setColWidths((prev) => { + if (!prev) { + return scaleWidthsToTotal(initialWidths ?? defaultWidths, newWidth, minColWidth); + } + const oldTotal = prev.reduce((s, w) => s + w, 0); + if (Math.abs(oldTotal - newWidth) <= 1) return prev; + return scaleWidthsToTotal(prev, newWidth, minColWidth); + }); + }); + + observer.observe(node); + observerRef.current = observer; + }, []); + + const handleResizeStart = useCallback((e, separatorIdx) => { + e.preventDefault(); + e.stopPropagation(); + + const startX = e.clientX; + const startWidths = [...colWidths]; + + setResizingIdx(separatorIdx); + + const onMouseMove = (moveE) => { + const delta = moveE.clientX - startX; + + // Hard stop: clamp so neither adjacent column goes below minColWidth + const clampedDelta = Math.max( + -(startWidths[separatorIdx] - minColWidth), + Math.min( + startWidths[separatorIdx + 1] - minColWidth, + delta + ) + ); + + const next = [...startWidths]; + next[separatorIdx] = startWidths[separatorIdx] + clampedDelta; + next[separatorIdx + 1] = startWidths[separatorIdx + 1] - clampedDelta; + + setColWidths(next); + }; + + const cleanup = () => { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + dragCleanupRef.current = null; + }; + + const onMouseUp = () => { + setResizingIdx(null); + cleanup(); + // Capture final widths for persistence — read directly from state via functional update + if (onResizeEnd) { + setColWidths((current) => { + onResizeEnd(current); + return current; + }); + } + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + dragCleanupRef.current = cleanup; + }, [colWidths, minColWidth]); + + return { + containerRef, + colWidths, + gridTemplateColumns, + separatorPositions, + resizingIdx, + handleResizeStart + }; +} diff --git a/packages/bruno-app/src/hooks/useResizableColumns/index.spec.js b/packages/bruno-app/src/hooks/useResizableColumns/index.spec.js new file mode 100644 index 000000000..ebb163341 --- /dev/null +++ b/packages/bruno-app/src/hooks/useResizableColumns/index.spec.js @@ -0,0 +1,240 @@ +const { describe, it, expect, jest, beforeEach } = require('@jest/globals'); +import { render, act } from '@testing-library/react'; +import React from 'react'; +import { useResizableColumns } from './index'; + +const CONTAINER_WIDTH = 1000; +const DEFAULT_WIDTHS = [100, 200, 400, 200, 100]; // sums to CONTAINER_WIDTH +const MIN_COL_WIDTH = 60; + +// Captures the latest hook return value on each render +let hookValue; + +function Fixture({ defaultWidths = DEFAULT_WIDTHS, minColWidth = MIN_COL_WIDTH }) { + const hook = useResizableColumns({ defaultWidths, minColWidth }); + hookValue = hook; + return
; +} + +const setup = (props = {}) => render(); + +const fireMouse = (type, clientX) => { + act(() => { + document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true })); + }); +}; + +const startDrag = (separatorIdx, startX) => { + act(() => { + hookValue.handleResizeStart( + { clientX: startX, preventDefault: jest.fn(), stopPropagation: jest.fn() }, + separatorIdx + ); + }); +}; + +describe('useResizableColumns', () => { + let triggerResize; + + beforeEach(() => { + hookValue = null; + global.ResizeObserver = class { + constructor(cb) { + triggerResize = (w) => cb([{ contentRect: { width: w } }]); + } + + observe() { + triggerResize(CONTAINER_WIDTH); + } + + disconnect() {} + }; + }); + + describe('initial state (before measurement)', () => { + beforeEach(() => { + global.ResizeObserver = class { + constructor() {} + observe() {} + disconnect() {} + }; + setup(); + }); + + it('returns fallback state before measurement', () => { + expect(hookValue.colWidths).toBeNull(); + expect(hookValue.gridTemplateColumns) + .toBe(`repeat(${DEFAULT_WIDTHS.length}, 1fr)`); + expect(hookValue.separatorPositions).toEqual([]); + }); + }); + + describe('container measurement', () => { + it('columns sum to container width after first measurement', () => { + setup(); + const total = hookValue.colWidths.reduce((s, w) => s + w, 0); + expect(total).toBe(CONTAINER_WIDTH); + }); + + it('falls back to equal distribution when container is narrower than minColWidth * columns', () => { + // 5 columns × 60px min = 300px minimum; container at 200px is unsatisfiable + setup(); + act(() => { triggerResize(200); }); + const total = hookValue.colWidths.reduce((s, w) => s + w, 0); + expect(total).toBe(200); + }); + + it('ignores a zero-width measurement', () => { + global.ResizeObserver = class { + constructor(cb) { + triggerResize = (w) => cb([{ contentRect: { width: w } }]); + } + + observe() { + triggerResize(0); + } + + disconnect() {} + }; + + setup(); + + expect(hookValue.colWidths).toBeNull(); + }); + + it('scales existing widths proportionally on container resize', () => { + setup(); + + act(() => { + triggerResize(800); + }); + + const total = hookValue.colWidths.reduce((s, w) => s + w, 0); + + expect(total).toBe(800); + }); + + it('preserves manually-resized proportions when container resizes again', () => { + setup(); + + startDrag(1, 500); + fireMouse('mousemove', 600); + fireMouse('mouseup', 600); + + const userWidths = [...hookValue.colWidths]; + + act(() => { + triggerResize(1500); + }); + + expect( + hookValue.colWidths.reduce((s, w) => s + w, 0) + ).toBe(1500); + + expect(hookValue.colWidths[1]).toBeCloseTo( + userWidths[1] * 1.5, + 0 + ); + }); + }); + + describe('drag resize', () => { + beforeEach(() => { + setup(); + }); + + it('dragging right grows left column and shrinks right column equally', () => { + const before = [...hookValue.colWidths]; + + startDrag(1, 500); + fireMouse('mousemove', 550); + + expect(hookValue.colWidths[1]).toBe(before[1] + 50); + expect(hookValue.colWidths[2]).toBe(before[2] - 50); + }); + + it('dragging left shrinks left column and grows right column equally', () => { + const before = [...hookValue.colWidths]; + + startDrag(1, 500); + fireMouse('mousemove', 450); + + expect(hookValue.colWidths[1]).toBe(before[1] - 50); + expect(hookValue.colWidths[2]).toBe(before[2] + 50); + }); + + it('only the two adjacent columns change', () => { + const before = [...hookValue.colWidths]; + + startDrag(1, 500); + fireMouse('mousemove', 550); + + expect(hookValue.colWidths[0]).toBe(before[0]); + expect(hookValue.colWidths[3]).toBe(before[3]); + expect(hookValue.colWidths[4]).toBe(before[4]); + }); + + it('hard stop: left column clamps at minColWidth', () => { + startDrag(0, 500); + fireMouse('mousemove', 100); + + expect(hookValue.colWidths[0]).toBe(MIN_COL_WIDTH); + }); + + it('hard stop: right neighbor absorbs exactly the full available delta', () => { + startDrag(0, 500); + fireMouse('mousemove', 100); + + expect(hookValue.colWidths[1]).toBe( + DEFAULT_WIDTHS[0] + DEFAULT_WIDTHS[1] - MIN_COL_WIDTH + ); + }); + + it('hard stop: right column clamps at minColWidth', () => { + startDrag(0, 500); + fireMouse('mousemove', 900); + + expect(hookValue.colWidths[1]).toBe(MIN_COL_WIDTH); + }); + + it('hard stop: left neighbor absorbs exactly the full available delta', () => { + startDrag(0, 500); + fireMouse('mousemove', 900); + + expect(hookValue.colWidths[0]).toBe( + DEFAULT_WIDTHS[0] + DEFAULT_WIDTHS[1] - MIN_COL_WIDTH + ); + }); + + it('resizingIdx tracks active separator and clears on mouseup', () => { + startDrag(2, 500); + + expect(hookValue.resizingIdx).toBe(2); + + fireMouse('mouseup', 500); + + expect(hookValue.resizingIdx).toBeNull(); + }); + + it('stops updating after mouseup', () => { + startDrag(1, 500); + fireMouse('mousemove', 550); + + const widthAfterDrag = hookValue.colWidths[1]; + + fireMouse('mouseup', 550); + fireMouse('mousemove', 700); + + expect(hookValue.colWidths[1]).toBe(widthAfterDrag); + }); + + it('unmounting during an active drag does not throw', () => { + const { unmount } = setup(); + + startDrag(1, 500); + fireMouse('mousemove', 600); + + expect(() => unmount()).not.toThrow(); + }); + }); +}); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/logs.js b/packages/bruno-app/src/providers/ReduxStore/slices/logs.js index 3f88ba3d2..9cd2f358d 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/logs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/logs.js @@ -9,6 +9,9 @@ export const TABS = { export const TAB_IDENFIERS = Object.values(TABS); +const MAX_LOGS = 1000; +const MAX_DEBUG_ERRORS = 500; + const initialState = { logs: [], debugErrors: [], @@ -31,10 +34,7 @@ const initialState = { OPTIONS: true }, selectedRequest: null, - selectedError: null, - maxLogs: 1000, - maxDebugErrors: 500, - requestDetailsPanelWidth: 400 + selectedError: null }; export const logsSlice = createSlice({ @@ -53,8 +53,8 @@ export const logsSlice = createSlice({ state.logs.push(newLog); - if (state.logs.length > state.maxLogs) { - state.logs = state.logs.slice(-state.maxLogs); + if (state.logs.length > MAX_LOGS) { + state.logs = state.logs.slice(-MAX_LOGS); } }, addDebugError: (state, action) => { @@ -72,8 +72,8 @@ export const logsSlice = createSlice({ state.debugErrors.push(newError); - if (state.debugErrors.length > state.maxDebugErrors) { - state.debugErrors = state.debugErrors.slice(-state.maxDebugErrors); + if (state.debugErrors.length > MAX_DEBUG_ERRORS) { + state.debugErrors = state.debugErrors.slice(-MAX_DEBUG_ERRORS); } }, clearLogs: (state) => { @@ -90,12 +90,6 @@ export const logsSlice = createSlice({ }, setActiveTab: (state, action) => { state.activeTab = action.payload; - if (action.payload !== 'network') { - state.selectedRequest = null; - } - if (action.payload !== 'debug') { - state.selectedError = null; - } }, updateFilter: (state, action) => { const { filterType, enabled } = action.payload; @@ -128,9 +122,6 @@ export const logsSlice = createSlice({ }, clearSelectedError: (state) => { state.selectedError = null; - }, - updateRequestDetailsPanelWidth: (state, action) => { - state.requestDetailsPanelWidth = action.payload.requestDetailsPanelWidth; } } }); @@ -150,8 +141,7 @@ export const { setSelectedRequest, clearSelectedRequest, setSelectedError, - clearSelectedError, - updateRequestDetailsPanelWidth + clearSelectedError } = logsSlice.actions; export default logsSlice.reducer; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/logs.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/logs.spec.js new file mode 100644 index 000000000..89e9babce --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/logs.spec.js @@ -0,0 +1,26 @@ +import reducer, { setActiveTab, setSelectedRequest, clearSelectedRequest } from './logs'; + +const mockRequest = { itemUid: 'item-1', timestamp: 1000, data: {} }; + +describe('setActiveTab', () => { + it('preserves selectedRequest when switching away from network tab', () => { + const stateWithRequest = reducer(undefined, setSelectedRequest(mockRequest)); + expect(stateWithRequest.selectedRequest).toEqual(mockRequest); + + const stateAfterSwitch = reducer(stateWithRequest, setActiveTab('console')); + expect(stateAfterSwitch.selectedRequest).toEqual(mockRequest); + }); + + it('preserves selectedRequest when switching back to network tab', () => { + let state = reducer(undefined, setSelectedRequest(mockRequest)); + state = reducer(state, setActiveTab('console')); + state = reducer(state, setActiveTab('network')); + expect(state.selectedRequest).toEqual(mockRequest); + }); + + it('clears selectedRequest only when explicitly dispatching clearSelectedRequest', () => { + let state = reducer(undefined, setSelectedRequest(mockRequest)); + state = reducer(state, clearSelectedRequest()); + expect(state.selectedRequest).toBeNull(); + }); +});