diff --git a/packages/bruno-app/jest.setup.js b/packages/bruno-app/jest.setup.js
index 1dbb39d85..5863193d4 100644
--- a/packages/bruno-app/jest.setup.js
+++ b/packages/bruno-app/jest.setup.js
@@ -1,3 +1,19 @@
+global.ResizeObserver = class ResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+};
+
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn()
+ }))
+});
+
jest.mock('nanoid', () => {
return {
nanoid: () => {}
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 de1aff766..8142d6520 100644
--- a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js
@@ -69,13 +69,22 @@ const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
min-height: 0; /* Important for proper flex behavior */
+ position: relative;
+ }
+
+ .col-separator {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 1px;
+ background: ${(props) => props.theme.console.border};
+ pointer-events: none;
+ z-index: 2;
}
.requests-header {
display: grid;
- grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
- gap: 12px;
- padding: 4px 16px;
+ padding: 0;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 10px;
@@ -83,6 +92,39 @@ const StyledWrapper = styled.div`
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
+
+ .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;
+ }
+
+ &:hover {
+ color: ${(props) => props.theme.console.messageColor};
+ }
+
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ svg {
+ flex-shrink: 0;
+ }
+
+ }
}
.requests-list {
@@ -94,9 +136,7 @@ const StyledWrapper = styled.div`
.request-row {
display: grid;
- grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
- gap: 12px;
- padding: 2px 16px;
+ padding: 0;
cursor: pointer;
transition: background-color 0.1s ease;
font-size: ${(props) => props.theme.font.size.sm};
@@ -107,12 +147,19 @@ const StyledWrapper = styled.div`
}
&.selected {
- padding-left: 13px;
background: ${(props) => props.theme.console.logHoverBg};
- border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
+ box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor};
}
}
+ .request-method {
+ padding: 2px 8px 2px 16px;
+ }
+
+ .request-status {
+ padding: 2px 8px;
+ }
+
.method-badge {
display: inline-flex;
align-items: center;
@@ -128,6 +175,7 @@ const StyledWrapper = styled.div`
}
.request-domain {
+ padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
@@ -135,6 +183,7 @@ const StyledWrapper = styled.div`
}
.request-path {
+ padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
@@ -143,19 +192,26 @@ const StyledWrapper = styled.div`
}
.request-time {
+ padding: 2px 8px;
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};
}
.request-duration {
+ padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
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;
}
+ .text-right {
+ text-align: right;
+ }
+
.request-size {
+ padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};
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 e424b690c..4fae5745c 100644
--- a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js
+++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js
@@ -1,12 +1,26 @@
-import React, { useMemo } from 'react';
+import React, { useMemo, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
- IconNetwork
+ IconNetwork,
+ IconArrowUp,
+ IconArrowDown
} from '@tabler/icons';
import {
setSelectedRequest
} from 'providers/ReduxStore/slices/logs';
import StyledWrapper from './StyledWrapper';
+import { getGridTemplate, getSeparatorPositions, 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: 'size', label: 'Size', width: 80, align: 'right' }
+];
const MethodBadge = ({ method }) => {
const methodLower = method?.toLowerCase() || 'get';
@@ -28,7 +42,7 @@ const StatusBadge = ({ status, statusCode }) => {
);
};
-const RequestRow = ({ request, isSelected, onClick }) => {
+const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
const { data } = request;
const { request: req, response: res, timestamp } = data;
@@ -82,6 +96,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
@@ -116,6 +133,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
const NetworkTab = () => {
const dispatch = useDispatch();
+ const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
+ const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []);
+ const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []);
const { networkFilters, selectedRequest } = useSelector((state) => state.logs);
const collections = useSelector((state) => state.collections.collections);
@@ -150,6 +170,21 @@ const NetworkTab = () => {
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 };
+ });
+ };
+
+ const sortedRequests = useMemo(
+ () => sortRequests(filteredRequests, sortConfig.key, sortConfig.direction),
+ [filteredRequests, sortConfig]
+ );
+
return (
@@ -161,26 +196,45 @@ const NetworkTab = () => {
) : (
-
-
Method
-
Status
-
Domain
-
Path
-
Time
-
Duration
-
Size
+
+ {COLUMNS.map((col) => (
+
handleHeaderClick(col.key)}
+ data-testid={`network-header-${col.key}`}
+ >
+ {col.label}
+ {sortConfig.key === col.key && (
+ sortConfig.direction === 'asc'
+ ?
+ :
+ )}
+
+ ))}
- {filteredRequests.map((request, index) => (
+ {sortedRequests.map((request, index) => (
handleRequestClick(request)}
+ gridTemplateColumns={gridTemplateColumns}
/>
))}
+
+ {separatorPositions.map((pos, i) =>
+ pos ? (
+
+ ) : null
+ )}
)}
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
new file mode 100644
index 000000000..5ae619b94
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.spec.js
@@ -0,0 +1,179 @@
+import React from 'react';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { configureStore } from '@reduxjs/toolkit';
+import { ThemeProvider } from 'providers/Theme';
+import NetworkTab from './index';
+
+const makeRequest = (overrides = {}) => ({
+ type: 'request',
+ timestamp: overrides.timestamp ?? 1000,
+ collectionUid: overrides.collectionUid ?? 'col-1',
+ itemUid: overrides.itemUid ?? 'item-1',
+ collectionName: 'Test Collection',
+ data: {
+ request: {
+ method: overrides.method ?? 'GET',
+ url: overrides.url ?? 'https://example.com/api/users'
+ },
+ response: {
+ status: overrides.status ?? 200,
+ statusCode: overrides.statusCode ?? 200,
+ // Use 'in' check so callers can explicitly pass undefined to test missing-value behaviour
+ ...('duration' in overrides ? { duration: overrides.duration } : { duration: 100 }),
+ ...('size' in overrides ? { size: overrides.size } : { size: 512 })
+ },
+ timestamp: overrides.timestamp ?? 1000
+ }
+});
+
+const ALL_FILTERS = { GET: true, POST: true, PUT: true, DELETE: true, PATCH: true, HEAD: true, OPTIONS: true };
+
+const renderNetworkTab = (requests = []) => {
+ const store = configureStore({
+ reducer: {
+ collections: (state = {
+ collections: [{
+ uid: 'col-1',
+ name: 'Test Collection',
+ timeline: requests
+ }]
+ }) => state,
+ logs: (state = {
+ networkFilters: ALL_FILTERS,
+ selectedRequest: null
+ }) => state
+ }
+ });
+
+ return render(
+
+
+
+
+
+ );
+};
+
+describe('sort state cycle', () => {
+ const requests = [
+ makeRequest({ itemUid: 'a', method: 'GET' }),
+ makeRequest({ itemUid: 'b', method: 'POST' })
+ ];
+
+ it('shows no sort icon by default', () => {
+ renderNetworkTab(requests);
+ expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
+ });
+
+ it('first click on a column shows ascending icon', () => {
+ renderNetworkTab(requests);
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
+ expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
+ });
+
+ it('second click on same column shows descending icon', () => {
+ renderNetworkTab(requests);
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ expect(screen.getByTestId('sort-icon-desc')).toBeInTheDocument();
+ expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
+ });
+
+ it('third click on same column clears sort', () => {
+ renderNetworkTab(requests);
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
+ });
+
+ it('clicking a different column resets to ascending on the new column', () => {
+ renderNetworkTab(requests);
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ fireEvent.click(screen.getByTestId('network-header-method')); // now desc
+ fireEvent.click(screen.getByTestId('network-header-status')); // switch column
+ // Should show asc on status, not desc
+ expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
+ expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
+ });
+
+ it('sort icon only appears on the active column', () => {
+ renderNetworkTab(requests);
+ fireEvent.click(screen.getByTestId('network-header-duration'));
+ // Only one icon total
+ expect(screen.getAllByTestId('sort-icon-asc')).toHaveLength(1);
+ });
+});
+
+describe('sort results', () => {
+ const getRowMethods = () =>
+ screen.getAllByTestId('network-request-row').map((row) =>
+ row.querySelector('.method-badge')?.textContent
+ );
+
+ it('sorts by method ascending (A → Z)', () => {
+ const requests = [
+ makeRequest({ itemUid: '1', method: 'POST' }),
+ makeRequest({ itemUid: '2', method: 'GET' }),
+ makeRequest({ itemUid: '3', method: 'DELETE' })
+ ];
+ renderNetworkTab(requests);
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
+ });
+
+ it('sorts by method descending (Z → A)', () => {
+ const requests = [
+ makeRequest({ itemUid: '1', method: 'POST' }),
+ makeRequest({ itemUid: '2', method: 'GET' }),
+ makeRequest({ itemUid: '3', method: 'DELETE' })
+ ];
+ renderNetworkTab(requests);
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
+ });
+
+ it('sorts by status ascending', () => {
+ const requests = [
+ makeRequest({ itemUid: '1', statusCode: 500 }),
+ makeRequest({ itemUid: '2', statusCode: 200 }),
+ makeRequest({ itemUid: '3', statusCode: 404 })
+ ];
+ renderNetworkTab(requests);
+ fireEvent.click(screen.getByTestId('network-header-status'));
+ const rows = screen.getAllByTestId('network-request-row');
+ const statuses = rows.map((r) => r.querySelector('.status-badge')?.textContent);
+ expect(statuses).toEqual(['200', '404', '500']);
+ });
+
+ it('sorts mixed-case methods case-insensitively', () => {
+ const requests = [
+ makeRequest({ itemUid: '1', method: 'post' }),
+ makeRequest({ itemUid: '2', method: 'GET' }),
+ makeRequest({ itemUid: '3', method: 'delete' })
+ ];
+ renderNetworkTab(requests);
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ // MethodBadge always renders uppercase; sort order should treat 'post' == 'POST'
+ expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
+ });
+
+ it('preserves insertion order when sort is cleared', () => {
+ const requests = [
+ makeRequest({ itemUid: '1', method: 'POST' }),
+ makeRequest({ itemUid: '2', method: 'GET' }),
+ makeRequest({ itemUid: '3', method: 'DELETE' })
+ ];
+ renderNetworkTab(requests);
+ // Sort then clear
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ fireEvent.click(screen.getByTestId('network-header-method'));
+ expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
+ });
+});
diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js
new file mode 100644
index 000000000..507fced80
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/utils.js
@@ -0,0 +1,57 @@
+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) {
+ case 'method': return req?.method?.toUpperCase() ?? '';
+ case 'status': return res?.statusCode || res?.status || 0;
+ case 'domain': {
+ try { return new URL(req?.url || '').hostname; } catch { return req?.url || ''; }
+ }
+ case 'path': {
+ try {
+ const u = new URL(req?.url || '');
+ return u.pathname + u.search;
+ } catch { return req?.url || ''; }
+ }
+ case 'time': return timestamp || 0;
+ case 'duration': return res?.duration || 0;
+ case 'size': return res?.size || 0;
+ default: return '';
+ }
+};
+
+export const sortRequests = (requests, key, direction) => {
+ if (!key || !direction) return requests;
+ return [...requests].sort((a, b) => {
+ const valueA = getSortValue(a, key);
+ const valueB = getSortValue(b, key);
+ if (valueA < valueB) return direction === 'asc' ? -1 : 1;
+ if (valueA > valueB) return direction === 'asc' ? 1 : -1;
+ return 0;
+ });
+};