mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
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
This commit is contained in:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -24819,6 +24819,15 @@
|
||||
"react-dom": ">=16.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtuoso": {
|
||||
"version": "4.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.17.0.tgz",
|
||||
"integrity": "sha512-od3pi2v13v31uzn5zPXC2u3ouISFCVhjFVFch2VvS2Cx7pWA2F1aJa3XhNTN2F07M3lhfnMnsmGeH+7wZICr7w==",
|
||||
"peerDependencies": {
|
||||
"react": ">=16 || >=17 || >= 18 || >= 19",
|
||||
"react-dom": ">=16 || >=17 || >= 18 || >=19"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -30075,6 +30084,7 @@
|
||||
"react-player": "^2.16.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-tooltip": "^5.5.2",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
"react-player": "^2.16.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-tooltip": "^5.5.2",
|
||||
"react-virtuoso": "^4.17.0",
|
||||
"sass": "^1.46.0",
|
||||
"semver": "^7.7.1",
|
||||
"shell-quote": "^1.8.3",
|
||||
|
||||
@@ -53,6 +53,17 @@ const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
div.tabs .action-icon {
|
||||
color: ${(props) => props.theme.dropdown.iconColor};
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.text};
|
||||
opacity: 1;
|
||||
background-color: ${(props) => props.theme.workspace.button.bg};
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { IconExclamationCircle, IconChevronRight, 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 { useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
const getContentMeta = (content) => {
|
||||
if (typeof content === 'object') {
|
||||
@@ -61,8 +59,7 @@ const TypeIcon = ({ type }) => {
|
||||
}[type];
|
||||
};
|
||||
|
||||
const WSMessageItem = ({ message, inFocus }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const WSMessageItem = memo(({ message, isOpen, onToggle }) => {
|
||||
const [showHex, setShowHex] = useState(false);
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const { displayedTheme } = useTheme();
|
||||
@@ -82,21 +79,23 @@ const WSMessageItem = ({ message, inFocus }) => {
|
||||
const dateDiff = Date.now() - new Date(message.timestamp).getTime();
|
||||
if (dateDiff < 1000 * 10) {
|
||||
setIsNew(true);
|
||||
setTimeout(() => {
|
||||
const timer = setTimeout(() => {
|
||||
notified.current = true;
|
||||
setIsNew(false);
|
||||
}, 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [message]);
|
||||
}, [message.timestamp]);
|
||||
|
||||
const canOpenMessage = !isInfo && !isError;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!canOpenMessage) return;
|
||||
onToggle?.(message.timestamp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (!node) return;
|
||||
if (inFocus) node.scrollIntoView();
|
||||
}}
|
||||
className={classnames('ws-message flex flex-col p-2', {
|
||||
'ws-incoming': isIncoming,
|
||||
'ws-outgoing': isOutgoing,
|
||||
@@ -111,10 +110,7 @@ const WSMessageItem = ({ message, inFocus }) => {
|
||||
'cursor-pointer': canOpenMessage,
|
||||
'cursor-not-allowed': !canOpenMessage
|
||||
})}
|
||||
onClick={(e) => {
|
||||
if (!canOpenMessage) return;
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<div className="flex min-w-0 shrink">
|
||||
<span className="message-type-icon">
|
||||
@@ -176,23 +172,87 @@ const WSMessageItem = ({ message, inFocus }) => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
const WSMessagesList = ({ messages = [] }) => {
|
||||
const virtuosoRef = useRef(null);
|
||||
const [scrollerElement, setScrollerElement] = useState(null);
|
||||
const [openMessages, setOpenMessages] = useState(new Set());
|
||||
const userScrolledAwayRef = useRef(false);
|
||||
|
||||
// Toggle message open/closed state by timestamp
|
||||
const handleMessageToggle = useCallback((timestamp) => {
|
||||
setOpenMessages((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(timestamp)) {
|
||||
next.delete(timestamp);
|
||||
} else {
|
||||
next.add(timestamp);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollerElement) return;
|
||||
|
||||
const handleWheel = (e) => {
|
||||
// deltaY < 0 means scrolling up
|
||||
if (e.deltaY < 0) {
|
||||
userScrolledAwayRef.current = true;
|
||||
}
|
||||
};
|
||||
|
||||
scrollerElement.addEventListener('wheel', handleWheel, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollerElement.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, [scrollerElement]);
|
||||
|
||||
const handleAtBottomStateChange = useCallback((atBottom) => {
|
||||
if (atBottom) {
|
||||
// User scrolled back to bottom, re-enable auto-scroll
|
||||
userScrolledAwayRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const followOutput = useCallback((isAtBottom) => {
|
||||
// Don't auto-scroll if user has scrolled away or has messages open
|
||||
if (userScrolledAwayRef.current || openMessages.size > 0) {
|
||||
return false;
|
||||
}
|
||||
if (isAtBottom) {
|
||||
return 'smooth';
|
||||
}
|
||||
return false;
|
||||
}, [openMessages.size]);
|
||||
|
||||
const renderItem = useCallback((_, msg) => {
|
||||
const isOpen = openMessages.has(msg.timestamp);
|
||||
return <WSMessageItem message={msg} isOpen={isOpen} onToggle={handleMessageToggle} />;
|
||||
}, [openMessages, handleMessageToggle]);
|
||||
|
||||
const computeItemKey = useCallback((_, msg) => {
|
||||
return msg.seq ?? msg.timestamp;
|
||||
}, []);
|
||||
|
||||
const WSMessagesList = ({ order = -1, messages = [] }) => {
|
||||
if (!messages.length) {
|
||||
return <StyledWrapper><div className="empty-state">No messages yet.</div></StyledWrapper>;
|
||||
}
|
||||
|
||||
// sort based on order, seq was newly added and might be missing in some cases and when missing,
|
||||
// the timestamp will be used instead
|
||||
const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * (-order));
|
||||
|
||||
return (
|
||||
<StyledWrapper className="ws-messages-list flex flex-col">
|
||||
{ordered.map((msg, idx, src) => {
|
||||
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
|
||||
return <WSMessageItem key={msg.seq ? msg.seq : msg.timestamp} inFocus={inFocus} id={idx} message={msg} />;
|
||||
})}
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
scrollerRef={setScrollerElement}
|
||||
data={messages}
|
||||
itemContent={renderItem}
|
||||
computeItemKey={computeItemKey}
|
||||
followOutput={followOutput}
|
||||
initialTopMostItemIndex={messages.length - 1}
|
||||
atBottomStateChange={handleAtBottomStateChange}
|
||||
/>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,11 +13,10 @@ import StyledWrapper from './StyledWrapper';
|
||||
import ResponseLayoutToggle from '../ResponseLayoutToggle';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
import WSMessagesList from './WSMessagesList';
|
||||
import WSResponseSortOrder from './WSResponseSortOrder';
|
||||
import WSResponseHeaders from './WSResponseHeaders';
|
||||
|
||||
const WSResult = ({ response }) => {
|
||||
return <WSMessagesList order={response?.sortOrder} messages={response.responses || []} />;
|
||||
return <WSMessagesList messages={response.responses || []} />;
|
||||
};
|
||||
|
||||
const WSResponsePane = ({ item, collection }) => {
|
||||
@@ -116,7 +115,6 @@ const WSResponsePane = ({ item, collection }) => {
|
||||
<>
|
||||
<ResponseLayoutToggle />
|
||||
<ResponseClear item={item} collection={collection} />
|
||||
<WSResponseSortOrder item={item} collection={collection} />
|
||||
<WSStatusCode
|
||||
status={response.statusCode}
|
||||
text={response.statusText}
|
||||
|
||||
@@ -3154,13 +3154,13 @@ export const collectionsSlice = createSlice({
|
||||
const item = findItemInCollection(collection, itemUid);
|
||||
if (data.data) {
|
||||
item.response.data ||= [];
|
||||
item.response.data = [{
|
||||
item.response.data.push({
|
||||
type: 'incoming',
|
||||
seq,
|
||||
message: data.data,
|
||||
messageHexdump: hexdump(data.data),
|
||||
timestamp: timestamp || Date.now()
|
||||
}].concat(item.response.data);
|
||||
});
|
||||
}
|
||||
if (item.response.dataBuffer && item.response.dataBuffer.length && data.dataBuffer) {
|
||||
item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]);
|
||||
|
||||
@@ -40,20 +40,6 @@ test.describe.serial('websockets', () => {
|
||||
await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached();
|
||||
});
|
||||
|
||||
test('websocket messages sorting can be changed', async ({ pageWithUserData: page, restartApp }) => {
|
||||
const locators = buildWebsocketCommonLocators(page);
|
||||
|
||||
await locators.toolbar.latestLast().click();
|
||||
|
||||
await expect(locators.messages().first().getByText('Closed')).toBeAttached();
|
||||
await expect(locators.messages().nth(1).getByText('Connected to ws://')).toBeAttached();
|
||||
|
||||
await locators.toolbar.latestFirst().click();
|
||||
|
||||
await expect(locators.messages().first().getByText('Connected to ws://')).toBeAttached();
|
||||
await expect(locators.messages().nth(1).getByText('Closed')).toBeAttached();
|
||||
});
|
||||
|
||||
test('websocket request can send messages', async ({ pageWithUserData: page, restartApp }) => {
|
||||
const locators = buildWebsocketCommonLocators(page);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user