Files
bruno/packages/bruno-app/src/components/RequestTabPanel/index.js
Chirag Chandrashekhar 5e1a36f8c8 fix: close previous SSE connection before sending new request (#7474)
* fix: update system proxy fetching to use finally (#7652)

* fix: update system proxy fetching to use finally for improved reliability

* Update packages/bruno-electron/src/index.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: allow file selection in multipart form without entering a key first (#7640)

* fix: close previous SSE connection before sending new request

When resending an SSE (Server-Sent Events) request using Cmd+Enter,
the previous connection was not being closed, causing connection leaks.

Changes:
- Add SSE cancellation logic to sendRequest action - checks for running
  stream and cancels it before sending new request
- Add return to cancelRequest action to make it properly chainable
- Simplify RequestTabPanel by removing duplicate cancel logic (now
  handled centrally in sendRequest)
- Add SSE endpoints to test server for e2e testing
- Add Playwright e2e test to verify SSE connection cancellation

* fix: address PR review feedback for SSE connection cancellation

- Use platform-aware modifier (Meta on macOS, Control on Linux/Windows)
  instead of hardcoded Meta+Enter for cross-platform CI compatibility
- Replace waitForTimeout with expect.poll for deterministic assertions
- Remove dead try/catch around cancelRequest (errors already swallowed
  by cancelRequest's internal .catch)

* fix: updated the test to check of connectionIds

---------

Co-authored-by: Sid <siddharth@usebruno.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Pooja <pooja@usebruno.com>
Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-03 01:59:11 +05:30

413 lines
15 KiB
JavaScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import find from 'lodash/find';
import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
import GraphQLRequestPane from 'components/RequestPane/GraphQLRequestPane';
import HttpRequestPane from 'components/RequestPane/HttpRequestPane';
import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
import ResponsePane from 'components/ResponsePane';
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
import { findItemInCollection } from 'utils/collections';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateGqlDocsOpen } from 'providers/ReduxStore/slices/tabs';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl/index';
import GrpcQueryUrl from 'components/RequestPane/GrpcQueryUrl/index';
import NetworkError from 'components/ResponsePane/NetworkError';
import RunnerResults from 'components/RunnerResults';
import VariablesEditor from 'components/VariablesEditor';
import CollectionSettings from 'components/CollectionSettings';
import { DocExplorer } from '@usebruno/graphql-docs';
import StyledWrapper from './StyledWrapper';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
import { produce } from 'immer';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
import FolderNotFound from './FolderNotFound';
import ExampleNotFound from './ExampleNotFound';
import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
import WSRequestPane from 'components/RequestPane/WSRequestPane';
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
import useKeybinding from 'hooks/useKeybinding';
import { ScopedPersistenceProvider } from 'hooks/usePersistedState/PersistedScopeProvider';
import ResponseExample from 'components/ResponseExample';
import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview';
import Preferences from 'components/Preferences';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
import OpenAPISyncTab from 'components/OpenAPISyncTab';
import OpenAPISpecTab from 'components/OpenAPISpecTab';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 490;
const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const RequestTabPanel = () => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const _collections = useSelector((state) => state.collections.collections);
const preferences = useSelector((state) => state.app.preferences);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
const isRequestTab = focusedTab && ['request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
useKeybinding('sendRequest', () => {
handleRun();
return false;
}, { enabled: !!isRequestTab, deps: [isRequestTab] });
// Use ref to avoid stale closure in event handlers
const isVerticalLayoutRef = useRef(isVerticalLayout);
useEffect(() => {
isVerticalLayoutRef.current = isVerticalLayout;
}, [isVerticalLayout]);
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
const collections = produce(_collections, (draft) => {
const collection = find(draft, (c) => c.uid === focusedTab?.collectionUid);
if (collection) {
// add selected global env variables to the collection object
const globalEnvironmentVariables = getGlobalEnvironmentVariables({
globalEnvironments,
activeGlobalEnvironmentUid
});
const globalEnvSecrets = getGlobalEnvironmentVariablesMasked({ globalEnvironments, activeGlobalEnvironmentUid });
collection.globalEnvironmentVariables = globalEnvironmentVariables;
collection.globalEnvSecrets = globalEnvSecrets;
}
});
const collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
const [dragging, setDragging] = useState(false);
const draggingRef = useRef(false);
const { left: leftPaneWidth, top: topPaneHeight, reset: resetPaneBoundaries, setTop: setTopPaneHeight, setLeft: setLeftPaneWidth } = useTabPaneBoundaries(activeTabUid);
const previousTopPaneHeight = useRef(null); // Store height before devtools opens
// Not a recommended pattern here to have the child component
// make a callback to set state, but treating this as an exception
const docExplorerRef = useRef(null);
const mainSectionRef = useRef(null);
const [schema, setSchema] = useState(null);
// Get gqlDocsOpen from Redux for persistence across tab switches
const showGqlDocs = focusedTab?.gqlDocsOpen || false;
const onSchemaLoad = useCallback((schema) => setSchema(schema), []);
const toggleDocs = useCallback(() => {
dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: !showGqlDocs }));
}, [dispatch, activeTabUid, showGqlDocs]);
const handleGqlClickReference = useCallback((reference) => {
if (docExplorerRef.current) {
docExplorerRef.current.showDocForReference(reference);
}
if (!showGqlDocs) {
dispatch(updateGqlDocsOpen({ uid: activeTabUid, gqlDocsOpen: true }));
}
}, [dispatch, activeTabUid, showGqlDocs]);
const handleMouseMove = useCallback((e) => {
if (!draggingRef.current || !mainSectionRef.current) return;
e.preventDefault();
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayoutRef.current) {
const newHeight = e.clientY - mainRect.top;
const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT;
// Clamp to bounds instead of returning early
const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight));
setTopPaneHeight(clampedHeight);
} else {
const newWidth = e.clientX - mainRect.left;
const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH;
// Clamp to bounds instead of returning early
const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth));
setLeftPaneWidth(clampedWidth);
}
}, [setTopPaneHeight, setLeftPaneWidth]);
const handleMouseUp = useCallback((e) => {
if (draggingRef.current) {
e.preventDefault();
draggingRef.current = false;
setDragging(false);
}
}, []);
const handleDragbarMouseDown = useCallback((e) => {
e.preventDefault();
draggingRef.current = true;
setDragging(true);
}, []);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
}, [handleMouseUp, handleMouseMove]);
useEffect(() => {
if (!isVerticalLayout) return;
if (isConsoleOpen) {
// Store current height before reducing
if (previousTopPaneHeight.current === null) {
previousTopPaneHeight.current = topPaneHeight;
}
// Reduce request pane height to make room for response pane when devtools is open
const maxHeight = 200;
if (topPaneHeight > maxHeight) {
setTopPaneHeight(maxHeight);
}
} else {
// Restore previous height when devtools closes
if (previousTopPaneHeight.current !== null) {
setTopPaneHeight(previousTopPaneHeight.current);
previousTopPaneHeight.current = null;
}
}
}, [isConsoleOpen, isVerticalLayout]);
if (typeof window == 'undefined') {
return <div></div>;
}
if (!activeTabUid || !focusedTab) {
return <div className="pb-4 px-4">Loading...</div>;
}
if (focusedTab.type === 'global-environment-settings') {
return <GlobalEnvironmentSettings />;
}
if (focusedTab.type === 'preferences') {
return <Preferences />;
}
if (focusedTab.type === 'workspaceOverview') {
return activeWorkspace ? <WorkspaceOverview workspace={activeWorkspace} /> : null;
}
if (focusedTab.type === 'workspaceEnvironments') {
return <GlobalEnvironmentSettings />;
}
if (!focusedTab.uid || !focusedTab.collectionUid) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
if (!collection || !collection.uid) {
return <div className="pb-4 px-4">Collection not found!</div>;
}
if (focusedTab.type === 'response-example') {
const item = findItemInCollection(collection, focusedTab.itemUid);
const example = item?.examples?.find((ex) => ex.uid === focusedTab.uid);
if (!example) {
return <ExampleNotFound itemUid={focusedTab.itemUid} exampleUid={focusedTab.uid} />;
}
return <ResponseExample item={item} collection={collection} example={example} />;
}
const item = findItemInCollection(collection, activeTabUid);
const isGrpcRequest = item?.type === 'grpc-request';
const isWsRequest = item?.type === 'ws-request';
if (focusedTab.type === 'collection-runner') {
return <RunnerResults collection={collection} />;
}
if (focusedTab.type === 'variables') {
return <VariablesEditor collection={collection} />;
}
if (focusedTab.type === 'collection-settings') {
return <CollectionSettings collection={collection} />;
}
if (focusedTab.type === 'collection-overview') {
return <CollectionOverview collection={collection} />;
}
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
if (!folder) {
return <FolderNotFound folderUid={focusedTab.folderUid} />;
}
return <FolderSettings collection={collection} folder={folder} />;
}
if (focusedTab.type === 'environment-settings') {
return <EnvironmentSettings collection={collection} />;
}
if (focusedTab.type === 'openapi-sync') {
return <OpenAPISyncTab collection={collection} />;
}
if (focusedTab.type === 'openapi-spec') {
return <OpenAPISpecTab collection={collection} />;
}
if (!item || !item.uid) {
return <RequestNotFound itemUid={activeTabUid} />;
}
if (item?.partial) {
return <RequestNotLoaded item={item} collection={collection} />;
}
if (item?.loading) {
return <RequestIsLoading item={item} />;
}
const handleRun = async () => {
const request = item.draft ? item.draft.request : item.request;
if (isGrpcRequest && !request.url) {
toast.error('Please enter a valid gRPC server URL');
return;
}
if (isGrpcRequest && !request.method) {
toast.error('Please select a gRPC method');
return;
}
if (isWsRequest && !request.url) {
toast.error('Please enter a valid WebSocket URL');
return;
}
if (item.requestState !== 'sending' && item.requestState !== 'queued') {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
}));
}
};
const renderQueryUrl = () => {
if (isGrpcRequest) {
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
}
if (isWsRequest) {
return <WsQueryUrl item={item} collection={collection} handleRun={handleRun} />;
}
return <QueryUrl item={item} collection={collection} handleRun={handleRun} />;
};
const renderRequestPane = () => {
switch (item.type) {
case 'graphql-request':
return (
<GraphQLRequestPane
item={item}
collection={collection}
onSchemaLoad={onSchemaLoad}
toggleDocs={toggleDocs}
handleGqlClickReference={handleGqlClickReference}
/>
);
case 'http-request':
return <HttpRequestPane item={item} collection={collection} />;
case 'grpc-request':
return <GrpcRequestPane item={item} collection={collection} handleRun={handleRun} />;
case 'ws-request':
return <WSRequestPane item={item} collection={collection} handleRun={handleRun} />;
default:
return null;
}
};
const renderResponsePane = () => {
switch (item.type) {
case 'grpc-request':
return <GrpcResponsePane item={item} collection={collection} response={item.response} />;
case 'ws-request':
return <WSResponsePane item={item} collection={collection} response={item.response} />;
default:
return <ResponsePane item={item} collection={collection} response={item.response} />;
}
};
const requestPaneStyle = isVerticalLayout
? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
}
: {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
};
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<StyledWrapper
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
isVerticalLayout ? 'vertical-layout' : ''
}`}
>
<div className="pt-3 pb-3 px-4">
{renderQueryUrl()}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane" data-testid="request-pane">
<div
className="px-4 h-full"
style={requestPaneStyle}
>
{renderRequestPane()}
</div>
</section>
<div
className="dragbar-wrapper"
onDoubleClick={(e) => {
e.preventDefault();
resetPaneBoundaries();
}}
onMouseDown={handleDragbarMouseDown}
>
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane">
{renderResponsePane()}
</section>
</section>
{item.type === 'graphql-request' ? (
<div className={`graphql-docs-explorer-container ${showGqlDocs ? '' : 'hidden'}`}>
<DocExplorer schema={schema} ref={(r) => (docExplorerRef.current = r)}>
<button className="mr-2" onClick={toggleDocs} aria-label="Close Documentation Explorer">
{'\u2715'}
</button>
</DocExplorer>
</div>
) : null}
</StyledWrapper>
</ScopedPersistenceProvider>
);
};
export default RequestTabPanel;