Skip to content

Commit 3351bf9

Browse files
authored
fix: websocket message scroll (#6503)
* fix: websocket message scroll * fix * fix: icon color * fix: sse message list * fix * rm: sort test * rm: WSResponseSortOrder * fix: auto scroll
1 parent 07fff42 commit 3351bf9

File tree

8 files changed

+114
-46
lines changed

8 files changed

+114
-46
lines changed

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/bruno-app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"react-player": "^2.16.0",
8585
"react-redux": "^7.2.9",
8686
"react-tooltip": "^5.5.2",
87+
"react-virtuoso": "^4.17.0",
8788
"sass": "^1.46.0",
8889
"semver": "^7.7.1",
8990
"shell-quote": "^1.8.3",

packages/bruno-app/src/components/ResponsePane/WsResponsePane/StyledWrapper.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ const StyledWrapper = styled.div`
5353
align-items: center;
5454
margin-left: 10px;
5555
}
56+
57+
div.tabs .action-icon {
58+
color: ${(props) => props.theme.dropdown.iconColor};
59+
opacity: 0.8;
60+
61+
&:hover {
62+
color: ${(props) => props.theme.text};
63+
opacity: 1;
64+
background-color: ${(props) => props.theme.workspace.button.bg};
65+
}
66+
}
5667
`;
5768

5869
export default StyledWrapper;

packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/StyledWrapper.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import styled from 'styled-components';
22

33
const StyledWrapper = styled.div`
4-
overflow-y: auto;
4+
flex: 1;
5+
min-height: 0;
6+
height: 100%;
57
68
.empty-state {
79
padding: 1rem;

packages/bruno-app/src/components/ResponsePane/WsResponsePane/WSMessagesList/index.js

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
1-
import React from 'react';
1+
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
22
import classnames from 'classnames';
33
import StyledWrapper from './StyledWrapper';
44
import { IconExclamationCircle, IconChevronRight, IconInfoCircle, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons';
55
import CodeEditor from 'components/CodeEditor/index';
66
import { useTheme } from 'providers/Theme';
7-
import { useState } from 'react';
87
import { useSelector } from 'react-redux';
9-
import { useRef } from 'react';
10-
import { useEffect } from 'react';
8+
import { Virtuoso } from 'react-virtuoso';
119

1210
const getContentMeta = (content) => {
1311
if (typeof content === 'object') {
@@ -61,8 +59,7 @@ const TypeIcon = ({ type }) => {
6159
}[type];
6260
};
6361

64-
const WSMessageItem = ({ message, inFocus }) => {
65-
const [isOpen, setIsOpen] = useState(false);
62+
const WSMessageItem = memo(({ message, isOpen, onToggle }) => {
6663
const [showHex, setShowHex] = useState(false);
6764
const preferences = useSelector((state) => state.app.preferences);
6865
const { displayedTheme } = useTheme();
@@ -82,21 +79,23 @@ const WSMessageItem = ({ message, inFocus }) => {
8279
const dateDiff = Date.now() - new Date(message.timestamp).getTime();
8380
if (dateDiff < 1000 * 10) {
8481
setIsNew(true);
85-
setTimeout(() => {
82+
const timer = setTimeout(() => {
8683
notified.current = true;
8784
setIsNew(false);
8885
}, 2500);
86+
return () => clearTimeout(timer);
8987
}
90-
}, [message]);
88+
}, [message.timestamp]);
9189

9290
const canOpenMessage = !isInfo && !isError;
9391

92+
const handleToggle = () => {
93+
if (!canOpenMessage) return;
94+
onToggle?.(message.timestamp);
95+
};
96+
9497
return (
9598
<div
96-
ref={(node) => {
97-
if (!node) return;
98-
if (inFocus) node.scrollIntoView();
99-
}}
10099
className={classnames('ws-message flex flex-col p-2', {
101100
'ws-incoming': isIncoming,
102101
'ws-outgoing': isOutgoing,
@@ -111,10 +110,7 @@ const WSMessageItem = ({ message, inFocus }) => {
111110
'cursor-pointer': canOpenMessage,
112111
'cursor-not-allowed': !canOpenMessage
113112
})}
114-
onClick={(e) => {
115-
if (!canOpenMessage) return;
116-
setIsOpen(!isOpen);
117-
}}
113+
onClick={handleToggle}
118114
>
119115
<div className="flex min-w-0 shrink">
120116
<span className="message-type-icon">
@@ -176,23 +172,87 @@ const WSMessageItem = ({ message, inFocus }) => {
176172
)}
177173
</div>
178174
);
179-
};
175+
});
176+
177+
const WSMessagesList = ({ messages = [] }) => {
178+
const virtuosoRef = useRef(null);
179+
const [scrollerElement, setScrollerElement] = useState(null);
180+
const [openMessages, setOpenMessages] = useState(new Set());
181+
const userScrolledAwayRef = useRef(false);
182+
183+
// Toggle message open/closed state by timestamp
184+
const handleMessageToggle = useCallback((timestamp) => {
185+
setOpenMessages((prev) => {
186+
const next = new Set(prev);
187+
if (next.has(timestamp)) {
188+
next.delete(timestamp);
189+
} else {
190+
next.add(timestamp);
191+
}
192+
return next;
193+
});
194+
}, []);
195+
196+
useEffect(() => {
197+
if (!scrollerElement) return;
198+
199+
const handleWheel = (e) => {
200+
// deltaY < 0 means scrolling up
201+
if (e.deltaY < 0) {
202+
userScrolledAwayRef.current = true;
203+
}
204+
};
205+
206+
scrollerElement.addEventListener('wheel', handleWheel, { passive: true });
207+
208+
return () => {
209+
scrollerElement.removeEventListener('wheel', handleWheel);
210+
};
211+
}, [scrollerElement]);
212+
213+
const handleAtBottomStateChange = useCallback((atBottom) => {
214+
if (atBottom) {
215+
// User scrolled back to bottom, re-enable auto-scroll
216+
userScrolledAwayRef.current = false;
217+
}
218+
}, []);
219+
220+
const followOutput = useCallback((isAtBottom) => {
221+
// Don't auto-scroll if user has scrolled away or has messages open
222+
if (userScrolledAwayRef.current || openMessages.size > 0) {
223+
return false;
224+
}
225+
if (isAtBottom) {
226+
return 'smooth';
227+
}
228+
return false;
229+
}, [openMessages.size]);
230+
231+
const renderItem = useCallback((_, msg) => {
232+
const isOpen = openMessages.has(msg.timestamp);
233+
return <WSMessageItem message={msg} isOpen={isOpen} onToggle={handleMessageToggle} />;
234+
}, [openMessages, handleMessageToggle]);
235+
236+
const computeItemKey = useCallback((_, msg) => {
237+
return msg.seq ?? msg.timestamp;
238+
}, []);
180239

181-
const WSMessagesList = ({ order = -1, messages = [] }) => {
182240
if (!messages.length) {
183241
return <StyledWrapper><div className="empty-state">No messages yet.</div></StyledWrapper>;
184242
}
185243

186-
// sort based on order, seq was newly added and might be missing in some cases and when missing,
187-
// the timestamp will be used instead
188-
const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * (-order));
189-
190244
return (
191245
<StyledWrapper className="ws-messages-list flex flex-col">
192-
{ordered.map((msg, idx, src) => {
193-
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
194-
return <WSMessageItem key={msg.seq ? msg.seq : msg.timestamp} inFocus={inFocus} id={idx} message={msg} />;
195-
})}
246+
<Virtuoso
247+
ref={virtuosoRef}
248+
scrollerRef={setScrollerElement}
249+
data={messages}
250+
itemContent={renderItem}
251+
computeItemKey={computeItemKey}
252+
followOutput={followOutput}
253+
initialTopMostItemIndex={messages.length - 1}
254+
atBottomStateChange={handleAtBottomStateChange}
255+
/>
196256
</StyledWrapper>
197257
);
198258
};

packages/bruno-app/src/components/ResponsePane/WsResponsePane/index.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,10 @@ import StyledWrapper from './StyledWrapper';
1313
import ResponseLayoutToggle from '../ResponseLayoutToggle';
1414
import ResponsiveTabs from 'ui/ResponsiveTabs';
1515
import WSMessagesList from './WSMessagesList';
16-
import WSResponseSortOrder from './WSResponseSortOrder';
1716
import WSResponseHeaders from './WSResponseHeaders';
1817

1918
const WSResult = ({ response }) => {
20-
return <WSMessagesList order={response?.sortOrder} messages={response.responses || []} />;
19+
return <WSMessagesList messages={response.responses || []} />;
2120
};
2221

2322
const WSResponsePane = ({ item, collection }) => {
@@ -116,7 +115,6 @@ const WSResponsePane = ({ item, collection }) => {
116115
<>
117116
<ResponseLayoutToggle />
118117
<ResponseClear item={item} collection={collection} />
119-
<WSResponseSortOrder item={item} collection={collection} />
120118
<WSStatusCode
121119
status={response.statusCode}
122120
text={response.statusText}

packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3154,13 +3154,13 @@ export const collectionsSlice = createSlice({
31543154
const item = findItemInCollection(collection, itemUid);
31553155
if (data.data) {
31563156
item.response.data ||= [];
3157-
item.response.data = [{
3157+
item.response.data.push({
31583158
type: 'incoming',
31593159
seq,
31603160
message: data.data,
31613161
messageHexdump: hexdump(data.data),
31623162
timestamp: timestamp || Date.now()
3163-
}].concat(item.response.data);
3163+
});
31643164
}
31653165
if (item.response.dataBuffer && item.response.dataBuffer.length && data.dataBuffer) {
31663166
item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]);

tests/websockets/connection.spec.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,6 @@ test.describe.serial('websockets', () => {
4040
await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached();
4141
});
4242

43-
test('websocket messages sorting can be changed', async ({ pageWithUserData: page, restartApp }) => {
44-
const locators = buildWebsocketCommonLocators(page);
45-
46-
await locators.toolbar.latestLast().click();
47-
48-
await expect(locators.messages().first().getByText('Closed')).toBeAttached();
49-
await expect(locators.messages().nth(1).getByText('Connected to ws://')).toBeAttached();
50-
51-
await locators.toolbar.latestFirst().click();
52-
53-
await expect(locators.messages().first().getByText('Connected to ws://')).toBeAttached();
54-
await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached();
55-
});
56-
5743
test('websocket request can send messages', async ({ pageWithUserData: page, restartApp }) => {
5844
const locators = buildWebsocketCommonLocators(page);
5945

0 commit comments

Comments
 (0)