From 1a1bfdce4c8dee04143856e0ea470a7d9f970b6a Mon Sep 17 00:00:00 2001 From: Siddharth Gelera Date: Tue, 16 Sep 2025 19:34:27 +0530 Subject: [PATCH] feat: implement WebSocket response sorting and enhance message handling - Added WSResponseSortOrder component for toggling message sort order. - Updated WSMessagesList to accept and utilize sort order. - Refactored message handling to use 'type' instead of 'direction'. - Enhanced response state management to include sort order. --- .../RequestPane/WsQueryUrl/index.js | 17 ++- .../WSMessagesList/StyledWrapper.js | 10 +- .../WsResponsePane/WSMessagesList/index.js | 123 ++++++++++++------ .../WSResponseSortOrder/StyledWrapper.js | 8 ++ .../WSResponseSortOrder/index.js | 32 +++++ .../WsResponsePane/WSStatusCode/index.js | 10 +- .../ResponsePane/WsResponsePane/index.js | 16 +-- .../ReduxStore/slices/collections/index.js | 42 ++++-- packages/bruno-requests/src/ws/ws-client.js | 6 +- 9 files changed, 185 insertions(+), 79 deletions(-) create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/index.js diff --git a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js index f8930f4ed..a61bb4a6c 100644 --- a/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js +++ b/packages/bruno-app/src/components/RequestPane/WsQueryUrl/index.js @@ -1,5 +1,6 @@ import { IconArrowRight, IconDeviceFloppy, IconPlugConnected, IconPlugConnectedX } from '@tabler/icons'; import { IconWebSocket } from 'components/Icons/Grpc'; +import classnames from "classnames" import SingleLineEditor from 'components/SingleLineEditor/index'; import { requestUrlChanged } from 'providers/ReduxStore/slices/collections'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; @@ -16,7 +17,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => { const dispatch = useDispatch(); const { theme, displayedTheme } = useTheme(); const [isConnectionActive, setIsConnectionActive] = useState(false); - // TODO: repear, better state for connecting + // TODO: reaper, better state for connecting const [isConnecting, setIsConnecting] = useState(false); const url = getPropertyFromDraftOrRequest(item, 'request.url'); const saveShortcut = isMacOS() ? '⌘S' : 'Ctrl+S'; @@ -55,6 +56,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => { .then(() => { toast.success('WebSocket connection closed'); setIsConnectionActive(false); + setIsConnecting(false) }) .catch((err) => { console.error('Failed to close WebSocket connection:', err); @@ -72,7 +74,8 @@ const WsQueryUrl = ({ item, collection, handleRun }) => { }; const handleConnect = (e) => { - connectWS(item, collection, undefined, undefined, {connectOnly:true}); + setIsConnecting(true) + connectWS(item, collection, undefined, undefined, {connectOnly:true}); }; const onSave = (finalValue) => { @@ -119,7 +122,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
{
diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js index a03c31dec..3709aee51 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js @@ -1,7 +1,15 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - overflow-y: auto; + overflow-y: auto; + + .ws-message:not(:last-child) { + border-bottom: 1px solid ${(props) => props.theme.table.border}; + } + + .ws-message:not(:last-child).open { + border-bottom-width: 0px; + } .ws-incoming { background: ${(props) => props.theme.bg}; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js index 1c429c565..bd86fa148 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js @@ -1,41 +1,41 @@ import React from 'react'; import classnames from 'classnames'; import StyledWrapper from './StyledWrapper'; -import { IconChevronUp, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons'; +import { IconChevronUp, IconInfoCircle, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons'; import CodeEditor from 'components/CodeEditor/index'; import { useTheme } from 'providers/Theme'; import { useState } from 'react'; import { useSelector } from 'react-redux'; +import _ from 'lodash'; +import { forwardRef } from 'react'; -// Example message structure: { direction: 'incoming' | 'outgoing', timestamp, data } - -const parseContent = (content) => { - if (typeof content === 'string') { - let isJSON = false; - let resultContent = content; - let trimmedContent = content; - try { - JSON.parse(content); - isJSON = true; - resultContent = JSON.stringify(resultContent, null, 2); - trimmedContent = JSON.stringify(resultContent, null, 0); - } catch (err) { - // digest error - } - - return { - type: isJSON ? 'application/json' : 'text/plain', - content: resultContent, - sliced: trimmedContent.slice(0, 30) - }; - } +const getContentMeta = (content) => { if (typeof content === 'object') { return { - type: 'application/json', - content: JSON.stringify(content, null, 2), - sliced: JSON.stringify(content, null, 0).slice(0, 30) + isJSON: true, + content: JSON.stringify(content, null, 0) }; } + try { + return { + isJSON: true, + content: JSON.stringify(JSON.parse(content), null, 0) + }; + } catch { + return { + isJSON: false, + content: content + }; + } +}; + +const parseContent = (content) => { + let contentMeta = getContentMeta(content); + return { + type: contentMeta.isJSON ? 'application/json' : 'text/plain', + content: contentMeta.isJSON ? JSON.stringify(JSON.parse(contentMeta.content), null, 2) : contentMeta.content, + sliced: contentMeta.content.slice(0, 30) + }; }; const getDataTypeText = (type) => { @@ -46,28 +46,56 @@ const getDataTypeText = (type) => { return textMap[type] ?? 'RAW'; }; -const WSMessageItem = ({ message, defaultOpen }) => { - const [isOpen, setIsOpen] = useState(defaultOpen ?? false); +/** + * + * @param {"incoming"|"outgoing"|"info"} type + */ +const TypeIcon = ({type})=>{ + const commonProps = { + size: 18 + } + return { + "incoming": , + "outgoing": , + "info": + }[type] +} + +const WSMessageItem = ({ message, isLast }) => { + const [isOpen, setIsOpen] = useState(false); const preferences = useSelector((state) => state.app.preferences); const { displayedTheme } = useTheme(); - const isIncoming = message.direction === 'incoming'; + const isIncoming = message.type === 'incoming'; + const isInfo = message.type === 'info'; let parsedContent = parseContent(message.message); const dataType = getDataTypeText(parsedContent.type); return (
{ + if (!node) return; + if (isLast) node.scrollIntoView(); + }} + className={classnames('ws-message flex flex-col py-2', { 'ws-incoming': isIncoming, - 'ws-outgoing': !isIncoming + 'ws-outgoing': !isIncoming, + 'open': isOpen })} >
{ - setIsOpen(!isOpen); + if(!isInfo){ + setIsOpen(!isOpen); + } }} >
@@ -77,10 +105,9 @@ const WSMessageItem = ({ message, defaultOpen }) => { isIncoming ? 'text-blue-700' : 'text-green-700' )} > - {isIncoming ? : } + - {!isOpen ? {parsedContent.sliced} : null} - {isOpen ? {dataType} : null} + {parsedContent.sliced}
{message.timestamp && ( @@ -96,7 +123,11 @@ const WSMessageItem = ({ message, defaultOpen }) => {
{isOpen && ( -
+
+
+
+ {isOpen ? {dataType} : null} +
{ ); }; -const WSMessagesList = ({ messages = [] }) => { +const WSMessagesList = ({ order = -1, messages = [] }) => { if (!messages.length) { return
No messages yet.
; } return ( - - {messages.map((msg, idx,src) => { - const isLast = idx === src.length-1 - return ; - })} + + {messages + .toSorted((x, y) => { + let a = order == -1 ? x : y + let b = order == -1 ? y : x + return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()); + }) + .map((msg, idx, src) => { + const isLast = src.length - 1 === idx; + return ; + })} ); }; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/StyledWrapper.js new file mode 100644 index 000000000..8c32a8bab --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/StyledWrapper.js @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + font-size: 0.8125rem; + color: ${(props) => props.theme.requestTabPanel.responseStatus}; +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/index.js new file mode 100644 index 000000000..9422da9ab --- /dev/null +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSResponseSortOrder/index.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { IconSortDescending2, IconSortAscending2 } from '@tabler/icons'; +import { useDispatch } from 'react-redux'; +import StyledWrapper from './StyledWrapper'; +import { wsUpdateResponseSortOrder } from 'providers/ReduxStore/slices/collections/index'; + +const WSResponseSortOrder = ({ collection, item }) => { + const dispatch = useDispatch(); + + const order = item.response?.initiatedWsResponse?.sortOrder ?? -1 + + const toggleSortOrder = ()=>{ + dispatch( + wsUpdateResponseSortOrder({ + itemUid: item.uid, + collectionUid: collection.uid, + }) + ); + } + + return ( + + + + ); +}; + +export default WSResponseSortOrder; diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/index.js index caa5404a7..f338c3397 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/index.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSStatusCode/index.js @@ -4,13 +4,11 @@ import wsStatusCodePhraseMap from './get-ws-status-code-phrase'; import StyledWrapper from './StyledWrapper'; const WSStatusCode = ({ status, text }) => { - // gRPC status codes: 0 is success, anything else is an error const getTabClassname = (status) => { - const isPending = text === 'PENDING' || text === 'STREAMING'; return classnames('ml-2', { - 'text-ok': parseInt(status) === 0, - 'text-pending': isPending, - 'text-error': parseInt(status) > 0 && !isPending + // ok if normal connect and normal closure + 'text-ok': parseInt(status) === 0 || parseInt(status) === 1000, + 'text-error': parseInt(status) !== 1000 && parseInt(status) !== 0 }); }; @@ -18,7 +16,7 @@ const WSStatusCode = ({ status, text }) => { return ( - {Number.isInteger(status) ?
{status}
: null} + {Number.isInteger(status) && status != 0 ?
{status}
: null} {statusText &&
{statusText}
}
); diff --git a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js index 6fe7e3744..0b414b530 100644 --- a/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js +++ b/packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js @@ -1,11 +1,9 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; import find from 'lodash/find'; -import classnames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs'; import Overlay from '../Overlay'; import Placeholder from '../Placeholder'; -import WSResponseHeaders from './WSResponseHeaders'; import WSStatusCode from './WSStatusCode'; import ResponseTime from '../ResponseTime/index'; import Timeline from '../Timeline'; @@ -15,6 +13,7 @@ import StyledWrapper from './StyledWrapper'; import ResponseLayoutToggle from '../ResponseLayoutToggle'; import Tab from 'components/Tab'; import WSMessagesList from './WSMessagesList'; +import WSResponseSortOrder from './WSResponseSortOrder'; const WSResult = ({ response }) => { return response.isError ? ( @@ -22,7 +21,7 @@ const WSResult = ({ response }) => { {response.error}
) : ( - + ); }; @@ -52,9 +51,6 @@ const WSResponsePane = ({ item, collection }) => { case 'response': { return ; } - case 'headers': { - return ; - } case 'timeline': { return ; } @@ -95,11 +91,6 @@ const WSResponsePane = ({ item, collection }) => { label: 'Messages', count: Array.isArray(response.responses) ? response.responses.length : 0 }, - { - name: 'headers', - label: 'Metadata', - count: Array.isArray(response.metadata) ? response.metadata.length : 0 - }, { name: 'timeline', label: 'Timeline' @@ -130,6 +121,7 @@ const WSResponsePane = ({ item, collection }) => { <> + { + const collection = findCollectionByUid(state.collections, action.payload.collectionUid); + + if (collection) { + const item = findItemInCollection(collection, action.payload.itemUid); + if (item) { + item.response.initiatedWsResponse.sortOrder = item.response?.initiatedWsResponse?.sortOrder ? -item.response.initiatedWsResponse.sortOrder : -1; + } + } } } }); @@ -2996,7 +3019,8 @@ export const { updateCollectionTagsList, updateActiveConnections, runWsRequestEvent, - wsResponseReceived + wsResponseReceived, + wsUpdateResponseSortOrder } = collectionsSlice.actions; export default collectionsSlice.reducer; diff --git a/packages/bruno-requests/src/ws/ws-client.js b/packages/bruno-requests/src/ws/ws-client.js index 3698bab88..961dd0da4 100644 --- a/packages/bruno-requests/src/ws/ws-client.js +++ b/packages/bruno-requests/src/ws/ws-client.js @@ -186,7 +186,7 @@ class WsClient { // Emit message sent event this.eventCallback('ws:message', requestId, collectionUid, { message: messageToSend, - direction: 'outgoing', + type: 'outgoing', timestamp: Date.now() }); } @@ -274,14 +274,14 @@ class WsClient { const message = JSON.parse(data.toString()); this.eventCallback('ws:message', requestId, collectionUid, { message, - direction: 'incoming', + type: 'incoming', timestamp: Date.now() }); } catch (error) { // If parsing fails, send as raw data this.eventCallback('ws:message', requestId, collectionUid, { message: data.toString(), - direction: 'incoming', + type: 'incoming', timestamp: Date.now() }); }