setCookieToDelete(null)}
+ cookieName={cookieToDelete.key}
+ onDelete={deleteCookieAction}
+ />
+ ) : null}
+ >
);
};
diff --git a/packages/bruno-app/src/components/Devtools/Console/DebugTab/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/DebugTab/StyledWrapper.js
new file mode 100644
index 000000000..a9c71b8c2
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/DebugTab/StyledWrapper.js
@@ -0,0 +1,163 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: ${(props) => props.theme.console.contentBg};
+ overflow: hidden;
+
+ .debug-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ flex-shrink: 0;
+ }
+
+ .debug-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: ${(props) => props.theme.console.titleColor};
+ font-size: 13px;
+ font-weight: 500;
+
+ .error-count {
+ color: ${(props) => props.theme.console.countColor};
+ font-size: 12px;
+ font-weight: 400;
+ }
+ }
+
+ .debug-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .control-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ background: transparent;
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 4px;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+ }
+
+ .debug-content {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ }
+
+ .debug-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: ${(props) => props.theme.console.emptyColor};
+ text-align: center;
+ gap: 8px;
+ padding: 40px 20px;
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 500;
+ }
+
+ span {
+ font-size: 12px;
+ opacity: 0.7;
+ }
+ }
+
+ .errors-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ min-height: 0;
+ }
+
+ .errors-header {
+ display: grid;
+ grid-template-columns: 1fr 200px 120px;
+ gap: 12px;
+ padding: 8px 16px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ font-size: 11px;
+ font-weight: 600;
+ color: ${(props) => props.theme.console.titleColor};
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ flex-shrink: 0;
+ }
+
+ .errors-list {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-height: 0;
+ }
+
+ .error-row {
+ display: grid;
+ grid-template-columns: 1fr 200px 120px;
+ gap: 12px;
+ padding: 8px 16px;
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ cursor: pointer;
+ transition: background-color 0.1s ease;
+ font-size: 12px;
+ align-items: center;
+
+ &:hover {
+ background: ${(props) => props.theme.console.logHoverBg};
+ }
+
+ &.selected {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
+ }
+ }
+
+ .error-message {
+ color: ${(props) => props.theme.console.messageColor};
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ }
+
+ .error-location {
+ color: ${(props) => props.theme.console.messageColor};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ font-size: 11px;
+ }
+
+ .error-time {
+ color: ${(props) => props.theme.console.timestampColor};
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ font-size: 11px;
+ text-align: right;
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Devtools/Console/DebugTab/index.js b/packages/bruno-app/src/components/Devtools/Console/DebugTab/index.js
new file mode 100644
index 000000000..1faa14ec7
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/DebugTab/index.js
@@ -0,0 +1,106 @@
+import React from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import { IconBug } from '@tabler/icons';
+import {
+ setSelectedError,
+ clearDebugErrors
+} from 'providers/ReduxStore/slices/logs';
+import StyledWrapper from './StyledWrapper';
+
+const ErrorRow = ({ error, isSelected, onClick }) => {
+ const formatTime = (timestamp) => {
+ const date = new Date(timestamp);
+ return date.toLocaleTimeString('en-US', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ fractionalSecondDigits: 3
+ });
+ };
+
+ const getShortMessage = (message, maxLength = 80) => {
+ if (!message) return 'Unknown error';
+ return message.length > maxLength ? message.substring(0, maxLength) + '...' : message;
+ };
+
+ const getLocation = (error) => {
+ if (error.filename) {
+ const filename = error.filename.split('/').pop(); // Get just the filename
+ if (error.lineno && error.colno) {
+ return `${filename}:${error.lineno}:${error.colno}`;
+ } else if (error.lineno) {
+ return `${filename}:${error.lineno}`;
+ }
+ return filename;
+ }
+ return '-';
+ };
+
+ return (
+
+
+ {getShortMessage(error.message)}
+
+
+
+ {getLocation(error)}
+
+
+
+ {formatTime(error.timestamp)}
+
+
+ );
+};
+
+const DebugTab = () => {
+ const dispatch = useDispatch();
+ const { debugErrors, selectedError } = useSelector(state => state.logs);
+
+ const handleErrorClick = (error) => {
+ dispatch(setSelectedError(error));
+ };
+
+ const handleClearErrors = () => {
+ dispatch(clearDebugErrors());
+ };
+
+ return (
+
+
+ {debugErrors.length === 0 ? (
+
+
+
No errors
+
console.error() calls will appear here
+
+ ) : (
+
+
+
Message
+
Location
+
Time
+
+
+
+ {debugErrors.map((error, index) => (
+ handleErrorClick(error)}
+ />
+ ))}
+
+
+ )}
+
+
+ );
+};
+
+export default DebugTab;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/StyledWrapper.js
new file mode 100644
index 000000000..94900df8f
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/StyledWrapper.js
@@ -0,0 +1,228 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: ${(props) => props.theme.console.contentBg};
+ border-left: 1px solid ${(props) => props.theme.console.border};
+ min-width: 400px;
+ max-width: 600px;
+ width: 40%;
+ overflow: hidden;
+
+ .panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ flex-shrink: 0;
+ }
+
+ .panel-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: ${(props) => props.theme.console.titleColor};
+ font-size: 13px;
+ font-weight: 500;
+
+ .error-time {
+ color: ${(props) => props.theme.console.countColor};
+ font-size: 11px;
+ font-weight: 400;
+ }
+ }
+
+ .close-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ color: ${(props) => props.theme.console.buttonHoverColor};
+ }
+ }
+
+ .panel-tabs {
+ display: flex;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ flex-shrink: 0;
+ }
+
+ .tab-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 16px;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 12px;
+ font-weight: 500;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ color: ${(props) => props.theme.console.buttonHoverColor};
+ }
+
+ &.active {
+ color: ${(props) => props.theme.console.checkboxColor};
+ border-bottom-color: ${(props) => props.theme.console.checkboxColor};
+ background: ${(props) => props.theme.console.contentBg};
+ }
+ }
+
+ .panel-content {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ background: ${(props) => props.theme.console.contentBg};
+ min-height: 0;
+ }
+
+ .tab-content {
+ padding: 16px;
+ height: 100%;
+ overflow-y: auto;
+ }
+
+ .section {
+ margin-bottom: 24px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ h4 {
+ margin: 0 0 12px 0;
+ font-size: 13px;
+ font-weight: 600;
+ color: ${(props) => props.theme.console.titleColor};
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+ }
+
+ .info-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ }
+
+ .info-item {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ label {
+ font-size: 11px;
+ font-weight: 600;
+ color: ${(props) => props.theme.console.titleColor};
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ span {
+ font-size: 12px;
+ color: ${(props) => props.theme.console.messageColor};
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ word-break: break-all;
+ line-height: 1.4;
+ }
+ }
+
+ .error-message-full {
+ color: ${(props) => props.theme.console.messageColor} !important;
+ background: ${(props) => props.theme.console.headerBg};
+ padding: 8px 12px;
+ border-radius: 4px;
+ border: 1px solid ${(props) => props.theme.console.border};
+ }
+
+ .file-path {
+ color: ${(props) => props.theme.console.checkboxColor} !important;
+ font-weight: 500 !important;
+ }
+
+ .report-section {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+
+ p {
+ margin: 0;
+ font-size: 12px;
+ color: ${(props) => props.theme.console.messageColor};
+ line-height: 1.4;
+ }
+ }
+
+ .report-button {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 16px;
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 6px;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 12px;
+ font-weight: 500;
+ text-decoration: none;
+ align-self: flex-start;
+
+ &:hover {
+ background: ${(props) => props.theme.console.checkboxColor};
+ color: white;
+ border-color: ${(props) => props.theme.console.checkboxColor};
+ }
+
+ span {
+ font-family: inherit;
+ }
+ }
+
+ .stack-trace-container,
+ .arguments-container {
+ background: ${(props) => props.theme.console.headerBg};
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 6px;
+ overflow: hidden;
+ }
+
+ .stack-trace,
+ .arguments {
+ margin: 0;
+ padding: 16px;
+ font-size: 11px;
+ line-height: 1.5;
+ color: ${(props) => props.theme.console.messageColor};
+ background: transparent;
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-x: auto;
+ max-height: 400px;
+ overflow-y: auto;
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/index.js b/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/index.js
new file mode 100644
index 000000000..91499b4a3
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/ErrorDetailsPanel/index.js
@@ -0,0 +1,268 @@
+import React, { useState } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import {
+ IconX,
+ IconBug,
+ IconFileText,
+ IconCode,
+ IconStack,
+ IconBrandGithub
+} from '@tabler/icons';
+import { clearSelectedError } from 'providers/ReduxStore/slices/logs';
+import { useApp } from 'providers/App';
+import platformLib from 'platform';
+import StyledWrapper from './StyledWrapper';
+
+const ErrorInfoTab = ({ error }) => {
+ const { version } = useApp();
+
+ const formatTimestamp = (timestamp) => {
+ const date = new Date(timestamp);
+ return date.toLocaleString();
+ };
+
+ const generateGitHubIssueUrl = () => {
+ const title = `Bug: ${error.message.substring(0, 50)}${error.message.length > 50 ? '...' : ''}`;
+
+ const body = `## Bug Report
+
+### Error Details
+- **Message**: ${error.message}
+- **File**: ${error.filename || 'Unknown'}
+- **Line**: ${error.lineno || 'Unknown'}:${error.colno || 'Unknown'}
+- **Timestamp**: ${formatTimestamp(error.timestamp)}
+
+### Environment
+- **Bruno Version**: ${version}
+- **OS**: ${platformLib.os.family} ${platformLib.os.version || ''}
+- **Browser**: ${platformLib.name} ${platformLib.version || ''}
+
+### Stack Trace
+\`\`\`
+${error.stack || 'No stack trace available'}
+\`\`\`
+
+### Arguments
+\`\`\`
+${error.args ? error.args.map((arg, index) => {
+ if (arg && typeof arg === 'object' && arg.__type === 'Error') {
+ return `[${index}]: Error: ${arg.message}`;
+ }
+ return `[${index}]: ${typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)}`;
+}).join('\n') : 'No arguments'}
+\`\`\`
+
+### Steps to Reproduce
+1.
+2.
+3.
+
+### Expected Behavior
+
+
+### Additional Context
+
+`;
+
+ const encodedTitle = encodeURIComponent(title);
+ const encodedBody = encodeURIComponent(body);
+
+ return `https://github.com/usebruno/bruno/issues/new?template=BLANK_ISSUE&title=${encodedTitle}&body=${encodedBody}`;
+ };
+
+ const handleReportIssue = () => {
+ const url = generateGitHubIssueUrl();
+ window.open(url, '_blank');
+ };
+
+ return (
+
+
+
Error Information
+
+
+ Message:
+ {error.message || 'No message available'}
+
+
+ {error.filename && (
+
+ File:
+ {error.filename}
+
+ )}
+
+ {error.lineno && (
+
+ Line:
+ {error.lineno}{error.colno ? `:${error.colno}` : ''}
+
+ )}
+
+
+ Timestamp:
+ {formatTimestamp(error.timestamp)}
+
+
+
+
+
+
Report Issue
+
+
Found a bug? Help us improve Bruno by reporting this error on GitHub.
+
+
+ Report Issue on GitHub
+
+
+
+
+ );
+};
+
+const StackTraceTab = ({ error }) => {
+ const formatStackTrace = (stack) => {
+ if (!stack) return 'Stack trace not available';
+
+ return stack
+ .split('\n')
+ .map(line => line.trim())
+ .filter(line => line.length > 0)
+ .join('\n');
+ };
+
+ return (
+
+
+
Stack Trace
+
+
+ {formatStackTrace(error.stack)}
+
+
+
+
+ );
+};
+
+const ArgumentsTab = ({ error }) => {
+ const formatArguments = (args) => {
+ if (!args || args.length === 0) return 'No arguments available';
+
+ try {
+ return args.map((arg, index) => {
+ // Handle special Error object format
+ if (arg && typeof arg === 'object' && arg.__type === 'Error') {
+ return `[${index}]: Error: ${arg.message}\n Name: ${arg.name}\n Stack: ${arg.stack || 'No stack trace'}`;
+ }
+
+ if (typeof arg === 'object' && arg !== null) {
+ return `[${index}]: ${JSON.stringify(arg, null, 2)}`;
+ }
+
+ return `[${index}]: ${String(arg)}`;
+ }).join('\n\n');
+ } catch (e) {
+ return 'Arguments could not be formatted';
+ }
+ };
+
+ return (
+
+
+
Arguments
+
+
+ {formatArguments(error.args)}
+
+
+
+
+ );
+};
+
+const ErrorDetailsPanel = () => {
+ const dispatch = useDispatch();
+ const { selectedError } = useSelector(state => state.logs);
+ const [activeTab, setActiveTab] = useState('info');
+
+ if (!selectedError) return null;
+
+ const handleClose = () => {
+ dispatch(clearSelectedError());
+ };
+
+ const formatTime = (timestamp) => {
+ const date = new Date(timestamp);
+ return date.toLocaleString();
+ };
+
+ const getTabContent = () => {
+ switch (activeTab) {
+ case 'info':
+ return ;
+ case 'stack':
+ return ;
+ case 'args':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+
+
+ Error Details
+ ({formatTime(selectedError.timestamp)})
+
+
+
+
+
+
+
+
+ setActiveTab('info')}
+ >
+
+ Info
+
+
+ setActiveTab('stack')}
+ >
+
+ Stack
+
+
+ setActiveTab('args')}
+ >
+
+ Args
+
+
+
+
+ {getTabContent()}
+
+
+ );
+};
+
+export default ErrorDetailsPanel;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js
new file mode 100644
index 000000000..2fc45bafe
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/StyledWrapper.js
@@ -0,0 +1,293 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: ${(props) => props.theme.console.contentBg};
+ overflow: hidden;
+
+ .network-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ flex-shrink: 0;
+ }
+
+ .network-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: ${(props) => props.theme.console.titleColor};
+ font-size: 13px;
+ font-weight: 500;
+
+ .request-count {
+ color: ${(props) => props.theme.console.countColor};
+ font-size: 12px;
+ font-weight: 400;
+ }
+ }
+
+ .network-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .network-content {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ min-height: 0; /* Important for proper flex behavior */
+ }
+
+ .network-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: ${(props) => props.theme.console.emptyColor};
+ text-align: center;
+ gap: 8px;
+ padding: 40px 20px;
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 500;
+ }
+
+ span {
+ font-size: 12px;
+ opacity: 0.7;
+ }
+ }
+
+ .requests-container {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+ min-height: 0; /* Important for proper flex behavior */
+ }
+
+ .requests-header {
+ display: grid;
+ grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
+ gap: 12px;
+ padding: 8px 16px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ font-size: 11px;
+ font-weight: 600;
+ color: ${(props) => props.theme.console.titleColor};
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ flex-shrink: 0;
+ }
+
+ .requests-list {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-height: 0; /* Important for proper scrolling */
+ }
+
+ .request-row {
+ display: grid;
+ grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
+ gap: 12px;
+ padding: 6px 16px;
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ cursor: pointer;
+ transition: background-color 0.1s ease;
+ font-size: 12px;
+ align-items: center;
+
+ &:hover {
+ background: ${(props) => props.theme.console.logHoverBg};
+ }
+
+ &.selected {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
+ }
+ }
+
+ .method-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 600;
+ color: white;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ min-width: 45px;
+ }
+
+ .status-badge {
+ font-weight: 600;
+ font-size: 12px;
+ }
+
+ .request-domain {
+ color: ${(props) => props.theme.console.messageColor};
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .request-path {
+ color: ${(props) => props.theme.console.messageColor};
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ }
+
+ .request-time {
+ color: ${(props) => props.theme.console.timestampColor};
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ font-size: 11px;
+ }
+
+ .request-duration {
+ color: ${(props) => props.theme.console.messageColor};
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ font-size: 11px;
+ text-align: right;
+ }
+
+ .request-size {
+ color: ${(props) => props.theme.console.messageColor};
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ font-size: 11px;
+ text-align: right;
+ }
+
+ .filter-dropdown {
+ position: relative;
+ }
+
+ .filter-dropdown-trigger {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px;
+ background: transparent;
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 4px;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 12px;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ color: ${(props) => props.theme.console.buttonHoverColor};
+ }
+
+ .filter-summary {
+ font-weight: 500;
+ min-width: 24px;
+ text-align: center;
+ }
+ }
+
+ .filter-dropdown-menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ min-width: 200px;
+ max-width: 250px;
+ background: ${(props) => props.theme.console.dropdownBg};
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ z-index: 1000;
+ overflow: hidden;
+ }
+
+ .filter-dropdown-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: ${(props) => props.theme.console.dropdownHeaderBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ font-size: 12px;
+ font-weight: 500;
+ color: ${(props) => props.theme.console.titleColor};
+ }
+
+ .filter-toggle-all {
+ background: transparent;
+ border: none;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 2px 4px;
+ border-radius: 2px;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ }
+ }
+
+ .filter-dropdown-options {
+ padding: 4px 0;
+ }
+
+ .filter-option {
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background: ${(props) => props.theme.console.optionHoverBg};
+ }
+
+ input[type="checkbox"] {
+ margin: 0 8px 0 0;
+ width: 14px;
+ height: 14px;
+ accent-color: ${(props) => props.theme.console.checkboxColor};
+ }
+ }
+
+ .filter-option-content {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ }
+
+ .filter-option-label {
+ color: ${(props) => props.theme.console.optionLabelColor};
+ font-size: 12px;
+ font-weight: 400;
+ }
+
+ .filter-option-count {
+ color: ${(props) => props.theme.console.optionCountColor};
+ font-size: 11px;
+ font-weight: 400;
+ margin-left: auto;
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js
new file mode 100644
index 000000000..6e4480398
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/NetworkTab/index.js
@@ -0,0 +1,302 @@
+import React, { useState, useRef, useEffect, useMemo } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import {
+ IconFilter,
+ IconChevronDown,
+ IconNetwork,
+} from '@tabler/icons';
+import {
+ updateNetworkFilter,
+ toggleAllNetworkFilters,
+ setSelectedRequest
+} from 'providers/ReduxStore/slices/logs';
+import StyledWrapper from './StyledWrapper';
+
+const MethodBadge = ({ method }) => {
+ const getMethodColor = (method) => {
+ switch (method?.toUpperCase()) {
+ case 'GET': return '#10b981';
+ case 'POST': return '#8b5cf6';
+ case 'PUT': return '#f59e0b';
+ case 'DELETE': return '#ef4444';
+ case 'PATCH': return '#06b6d4';
+ case 'HEAD': return '#6b7280';
+ case 'OPTIONS': return '#84cc16';
+ default: return '#6b7280';
+ }
+ };
+
+ return (
+
+ {method?.toUpperCase() || 'GET'}
+
+ );
+};
+
+const StatusBadge = ({ status, statusCode }) => {
+ const getStatusColor = (code) => {
+ if (code >= 200 && code < 300) return '#10b981';
+ if (code >= 300 && code < 400) return '#f59e0b';
+ if (code >= 400 && code < 500) return '#ef4444';
+ if (code >= 500) return '#dc2626';
+ return '#6b7280';
+ };
+
+ const displayStatus = statusCode || status;
+
+ return (
+
+ {displayStatus}
+
+ );
+};
+
+const NetworkFilterDropdown = ({ filters, requestCounts, onFilterToggle, onToggleAll }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ const allFiltersEnabled = Object.values(filters).every(f => f);
+ const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ return (
+
+
setIsOpen(!isOpen)}
+ title="Filter requests by method"
+ >
+
+
+ {activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
+
+
+
+
+ {isOpen && (
+
+
+ Filter by Method
+ onToggleAll(!allFiltersEnabled)}
+ >
+ {allFiltersEnabled ? 'Hide All' : 'Show All'}
+
+
+
+
+ {Object.keys(filters).map(method => (
+
+ onFilterToggle(method, e.target.checked)}
+ />
+
+
+ {method}
+ ({requestCounts[method] || 0})
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+const RequestRow = ({ request, isSelected, onClick }) => {
+ const { data } = request;
+ const { request: req, response: res, timestamp } = data;
+
+ const formatTime = (timestamp) => {
+ const date = new Date(timestamp);
+ return date.toLocaleTimeString('en-US', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ fractionalSecondDigits: 3
+ });
+ };
+
+ const formatDuration = (duration) => {
+ if (!duration) return '-';
+ if (duration < 1000) return `${Math.round(duration)}ms`;
+ return `${(duration / 1000).toFixed(2)}s`;
+ };
+
+ const formatSize = (size) => {
+ if (!size) return '-';
+ if (size < 1024) return `${size}B`;
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
+ return `${(size / (1024 * 1024)).toFixed(1)}MB`;
+ };
+
+ const getUrl = () => {
+ return req?.url || 'Unknown URL';
+ };
+
+ const getDomain = () => {
+ try {
+ const url = new URL(getUrl());
+ return url.hostname;
+ } catch {
+ return getUrl();
+ }
+ };
+
+ const getPath = () => {
+ try {
+ const url = new URL(getUrl());
+ return url.pathname + url.search;
+ } catch {
+ return getUrl();
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {getDomain()}
+
+
+
+ {getPath()}
+
+
+
+ {formatTime(timestamp)}
+
+
+
+ {formatDuration(res?.duration)}
+
+
+
+ {formatSize(res?.size)}
+
+
+ );
+};
+
+const NetworkTab = () => {
+ const dispatch = useDispatch();
+ const { networkFilters, selectedRequest } = useSelector(state => state.logs);
+ const collections = useSelector(state => state.collections.collections);
+
+ const allRequests = useMemo(() => {
+ const requests = [];
+
+ collections.forEach(collection => {
+ if (collection.timeline) {
+ collection.timeline
+ .filter(entry => entry.type === 'request')
+ .forEach(entry => {
+ requests.push({
+ ...entry,
+ collectionName: collection.name,
+ collectionUid: collection.uid
+ });
+ });
+ }
+ });
+
+ return requests.sort((a, b) => a.timestamp - b.timestamp);
+ }, [collections]);
+
+ const filteredRequests = useMemo(() => {
+ return allRequests.filter(request => {
+ const method = request.data?.request?.method?.toUpperCase() || 'GET';
+ return networkFilters[method];
+ });
+ }, [allRequests, networkFilters]);
+
+ const requestCounts = useMemo(() => {
+ return allRequests.reduce((counts, request) => {
+ const method = request.data?.request?.method?.toUpperCase() || 'GET';
+ counts[method] = (counts[method] || 0) + 1;
+ return counts;
+ }, {});
+ }, [allRequests]);
+
+ const handleFilterToggle = (method, enabled) => {
+ dispatch(updateNetworkFilter({ method, enabled }));
+ };
+
+ const handleToggleAllFilters = (enabled) => {
+ dispatch(toggleAllNetworkFilters(enabled));
+ };
+
+ const handleRequestClick = (request) => {
+ dispatch(setSelectedRequest(request));
+ };
+
+ return (
+
+
+ {filteredRequests.length === 0 ? (
+
+
+
No network requests
+
Requests will appear here as you make API calls
+
+ ) : (
+
+
+
Method
+
Status
+
Domain
+
Path
+
Time
+
Duration
+
Size
+
+
+
+ {filteredRequests.map((request, index) => (
+ handleRequestClick(request)}
+ />
+ ))}
+
+
+ )}
+
+
+ );
+};
+
+export default NetworkTab;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js
new file mode 100644
index 000000000..3cc9ba03d
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/StyledWrapper.js
@@ -0,0 +1,347 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ background: ${(props) => props.theme.console.contentBg};
+ border-left: 1px solid ${(props) => props.theme.console.border};
+ min-width: 400px;
+ max-width: 600px;
+ width: 40%;
+ overflow: hidden;
+
+ .panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ flex-shrink: 0;
+ }
+
+ .panel-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: ${(props) => props.theme.console.titleColor};
+ font-size: 13px;
+ font-weight: 500;
+
+ .request-time {
+ color: ${(props) => props.theme.console.countColor};
+ font-size: 11px;
+ font-weight: 400;
+ }
+ }
+
+ .close-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ color: ${(props) => props.theme.console.buttonHoverColor};
+ }
+ }
+
+ .panel-tabs {
+ display: flex;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ flex-shrink: 0;
+ }
+
+ .tab-button {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 16px;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 12px;
+ font-weight: 500;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ color: ${(props) => props.theme.console.buttonHoverColor};
+ }
+
+ &.active {
+ color: ${(props) => props.theme.console.checkboxColor};
+ border-bottom-color: ${(props) => props.theme.console.checkboxColor};
+ background: ${(props) => props.theme.console.contentBg};
+ }
+ }
+
+ .panel-content {
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 16px;
+ min-height: 0;
+ height: 0;
+ }
+
+ .tab-content {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+ min-height: min-content;
+ }
+
+ .section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ h4 {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 600;
+ color: ${(props) => props.theme.console.titleColor};
+ padding-bottom: 4px;
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ }
+ }
+
+ .info-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .info-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+
+ .label {
+ font-size: 11px;
+ font-weight: 600;
+ color: ${(props) => props.theme.console.countColor};
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .value {
+ font-size: 12px;
+ color: ${(props) => props.theme.console.messageColor};
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ word-break: break-all;
+ padding: 4px 8px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-radius: 4px;
+ border: 1px solid ${(props) => props.theme.console.border};
+ }
+ }
+
+ .headers-table,
+ .timeline-table {
+ overflow: auto;
+ border-radius: 4px;
+ border: 1px solid ${(props) => props.theme.console.border};
+ max-height: 300px;
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12px;
+ background: ${(props) => props.theme.console.headerBg};
+
+ thead {
+ background: ${(props) => props.theme.console.dropdownHeaderBg};
+ position: sticky;
+ top: 0;
+ z-index: 10;
+
+ td {
+ padding: 8px 12px;
+ font-weight: 600;
+ color: ${(props) => props.theme.console.titleColor};
+ text-transform: uppercase;
+ font-size: 11px;
+ letter-spacing: 0.5px;
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ }
+ }
+
+ tbody {
+ tr {
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &:nth-child(odd) {
+ background: ${(props) => props.theme.console.contentBg};
+ }
+
+ &:hover {
+ background: ${(props) => props.theme.console.logHoverBg};
+ }
+ }
+
+ td {
+ padding: 8px 12px;
+ vertical-align: top;
+ word-break: break-word;
+ }
+ }
+ }
+ }
+
+ .header-name,
+ .timeline-phase {
+ color: ${(props) => props.theme.console.countColor};
+ font-weight: 600;
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ min-width: 120px;
+ }
+
+ .header-value,
+ .timeline-message {
+ color: ${(props) => props.theme.console.messageColor};
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ word-break: break-all;
+ }
+
+ .timeline-duration {
+ color: ${(props) => props.theme.console.timestampColor};
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ text-align: right;
+ min-width: 80px;
+ }
+
+ .code-block {
+ background: ${(props) => props.theme.console.headerBg};
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 4px;
+ padding: 12px;
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ font-size: 11px;
+ line-height: 1.4;
+ color: ${(props) => props.theme.console.messageColor};
+ overflow: auto;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 400px;
+ margin: 0;
+ }
+
+ .empty-state {
+ padding: 12px;
+ text-align: center;
+ color: ${(props) => props.theme.console.emptyColor};
+ font-style: italic;
+ font-size: 12px;
+ background: ${(props) => props.theme.console.headerBg};
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 4px;
+ }
+
+ .response-body-container {
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 4px;
+ overflow: hidden;
+ background: ${(props) => props.theme.console.headerBg};
+ height: 400px;
+ display: flex;
+ flex-direction: column;
+
+ .w-full.h-full.relative.flex {
+ height: 100% !important;
+ width: 100% !important;
+ background: ${(props) => props.theme.console.headerBg} !important;
+ display: flex !important;
+ flex-direction: column !important;
+ }
+
+ div[role="tablist"] {
+ background: ${(props) => props.theme.console.dropdownHeaderBg};
+ padding: 8px 12px;
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ display: flex !important;
+ gap: 8px !important;
+ flex-wrap: wrap !important;
+ align-items: center !important;
+ min-height: 40px !important;
+ flex-shrink: 0 !important;
+
+ > div {
+ color: ${(props) => props.theme.console.buttonColor};
+ font-size: 12px !important;
+ padding: 6px 12px !important;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+ cursor: pointer;
+ border: 1px solid ${(props) => props.theme.console.border};
+ background: ${(props) => props.theme.console.contentBg};
+ white-space: nowrap !important;
+ min-width: auto !important;
+ height: auto !important;
+ line-height: 1.2 !important;
+ font-weight: 500 !important;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ color: ${(props) => props.theme.console.buttonHoverColor};
+ border-color: ${(props) => props.theme.console.buttonHoverBg};
+ }
+
+ &.active {
+ background: ${(props) => props.theme.console.checkboxColor};
+ color: white;
+ border-color: ${(props) => props.theme.console.checkboxColor};
+ }
+ }
+ }
+ .response-filter {
+ position: absolute !important;
+ bottom: 8px !important;
+ right: 8px !important;
+ left: 8px !important;
+ z-index: 10 !important;
+ }
+ }
+
+ .network-logs-container {
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 4px;
+ overflow: hidden;
+ background: ${(props) => props.theme.console.headerBg};
+ min-height: 200px;
+ max-height: 400px;
+
+ .network-logs {
+ background: ${(props) => props.theme.console.contentBg} !important;
+ color: ${(props) => props.theme.console.messageColor} !important;
+ height: 100% !important;
+ max-height: 400px !important;
+
+ pre {
+ color: ${(props) => props.theme.console.messageColor} !important;
+ font-size: 11px !important;
+ line-height: 1.4 !important;
+ padding: 12px !important;
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js
new file mode 100644
index 000000000..d9af83aad
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/RequestDetailsPanel/index.js
@@ -0,0 +1,242 @@
+import React, { useState } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import {
+ IconX,
+ IconFileText,
+ IconArrowRight,
+ IconNetwork
+} from '@tabler/icons';
+import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs';
+import QueryResult from 'components/ResponsePane/QueryResult';
+import Network from 'components/ResponsePane/Timeline/TimelineItem/Network';
+import StyledWrapper from './StyledWrapper';
+import { uuid } from 'utils/common/index';
+
+const RequestTab = ({ request, response }) => {
+ const formatHeaders = (headers) => {
+ if (!headers) return [];
+ if (Array.isArray(headers)) return headers;
+ return Object.entries(headers).map(([key, value]) => ({ name: key, value }));
+ };
+
+ const formatBody = (body) => {
+ if (!body) return 'No body';
+ if (typeof body === 'string') return body;
+ return JSON.stringify(body, null, 2);
+ };
+
+ return (
+
+
+
General
+
+
+ Request URL:
+ {request?.url || 'N/A'}
+
+
+ Request Method:
+ {request?.method || 'GET'}
+
+
+
+
+
+
Request Headers
+ {formatHeaders(request?.headers).length > 0 ? (
+
+
+
+
+ Name
+ Value
+
+
+
+ {formatHeaders(request.headers).map((header, index) => (
+
+ {header.name}
+ {header.value}
+
+ ))}
+
+
+
+ ) : (
+
No headers
+ )}
+
+
+ {request?.data && (
+
+
Request Body
+
{formatBody(request.data)}
+
+ )}
+
+ );
+};
+
+const ResponseTab = ({ response, request, collection }) => {
+ const formatHeaders = (headers) => {
+ if (!headers) return [];
+ if (Array.isArray(headers)) return headers;
+ return Object.entries(headers).map(([key, value]) => ({ name: key, value }));
+ };
+
+ return (
+
+
+
Response Headers
+ {formatHeaders(response?.headers).length > 0 ? (
+
+
+
+
+ Name
+ Value
+
+
+
+ {formatHeaders(response.headers).map((header, index) => (
+
+ {header.name}
+ {header.value}
+
+ ))}
+
+
+
+ ) : (
+
No headers
+ )}
+
+
+
+
Response Body
+
+ {response?.data || response?.dataBuffer ? (
+
+ ) : (
+
No response data
+ )}
+
+
+
+ );
+};
+
+const NetworkTab = ({ response }) => {
+ const timeline = response?.timeline || [];
+
+ return (
+
+
+
Network Logs
+
+ {timeline.length > 0 ? (
+
+ ) : (
+
No network logs available
+ )}
+
+
+
+ );
+};
+
+const RequestDetailsPanel = () => {
+ const dispatch = useDispatch();
+ const { selectedRequest } = useSelector(state => state.logs);
+ const collections = useSelector(state => state.collections.collections);
+ const [activeTab, setActiveTab] = useState('request');
+
+ if (!selectedRequest) return null;
+
+ const { data } = selectedRequest;
+ const { request, response } = data;
+
+ const collection = collections.find(c => c.uid === selectedRequest.collectionUid);
+
+ const handleClose = () => {
+ dispatch(clearSelectedRequest());
+ };
+
+ const formatTime = (timestamp) => {
+ const date = new Date(timestamp);
+ return date.toLocaleString();
+ };
+
+ const getTabContent = () => {
+ switch (activeTab) {
+ case 'request':
+ return ;
+ case 'response':
+ return ;
+ case 'network':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ return (
+
+
+
+
+ Request Details
+ ({formatTime(selectedRequest.timestamp)})
+
+
+
+
+
+
+
+
+ setActiveTab('request')}
+ >
+
+ Request
+
+
+ setActiveTab('response')}
+ >
+
+ Response
+
+
+ setActiveTab('network')}
+ >
+
+ Network
+
+
+
+
+ {getTabContent()}
+
+
+ );
+};
+
+export default RequestDetailsPanel;
diff --git a/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js b/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js
new file mode 100644
index 000000000..117d176e6
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/StyledWrapper.js
@@ -0,0 +1,520 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ width: 100%;
+ height: 100%;
+ background: ${(props) => props.theme.console.bg};
+ border-top: 1px solid ${(props) => props.theme.console.border};
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+
+ .console-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 16px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ flex-shrink: 0;
+ position: relative;
+ }
+
+ .console-tabs {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ }
+
+ .console-tab {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 12px;
+ font-weight: 500;
+ border-radius: 4px 4px 0 0;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ color: ${(props) => props.theme.console.buttonHoverColor};
+ }
+
+ &.active {
+ color: ${(props) => props.theme.console.checkboxColor};
+ border-bottom-color: ${(props) => props.theme.console.checkboxColor};
+ background: ${(props) => props.theme.console.contentBg};
+ }
+ }
+
+ .console-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .console-content {
+ flex: 1;
+ overflow: hidden;
+ background: ${(props) => props.theme.console.contentBg};
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .tab-content {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ }
+
+ .tab-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ flex-shrink: 0;
+ }
+
+ .tab-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: ${(props) => props.theme.console.titleColor};
+ font-size: 13px;
+ font-weight: 500;
+
+ .log-count {
+ color: ${(props) => props.theme.console.countColor};
+ font-size: 12px;
+ font-weight: 400;
+ }
+ }
+
+ .tab-controls {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .tab-content-area {
+ flex: 1;
+ overflow-y: auto;
+ background: ${(props) => props.theme.console.contentBg};
+ min-height: 0;
+ }
+
+ .network-with-details {
+ display: flex;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ .network-main {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ }
+
+ .debug-with-details {
+ display: flex;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ .debug-main {
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ }
+
+ .filter-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-right: 8px;
+ padding-right: 8px;
+ border-right: 1px solid ${(props) => props.theme.console.border};
+ }
+
+ .action-controls {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .control-button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ background: transparent;
+ border: none;
+ border-radius: 4px;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ color: ${(props) => props.theme.console.buttonHoverColor};
+ }
+
+ &.close-button:hover {
+ background: #e81123;
+ color: white;
+ }
+ }
+
+ .filter-dropdown {
+ position: relative;
+ }
+
+ .filter-dropdown-trigger {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 8px;
+ background: transparent;
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 4px;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ transition: all 0.2s ease;
+ font-size: 12px;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ color: ${(props) => props.theme.console.buttonHoverColor};
+ border-color: ${(props) => props.theme.console.border};
+ }
+
+ .filter-summary {
+ font-weight: 500;
+ min-width: 24px;
+ text-align: center;
+ }
+ }
+
+ .filter-dropdown-menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ min-width: 200px;
+ max-width: 250px;
+ background: ${(props) => props.theme.console.dropdownBg};
+ border: 1px solid ${(props) => props.theme.console.border};
+ border-radius: 6px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ z-index: 1000;
+ overflow: hidden;
+
+ &.right {
+ left: auto;
+ right: 0;
+ }
+ }
+
+ .filter-dropdown-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ background: ${(props) => props.theme.console.dropdownHeaderBg};
+ border-bottom: 1px solid ${(props) => props.theme.console.border};
+ font-size: 12px;
+ font-weight: 500;
+ color: ${(props) => props.theme.console.titleColor};
+ }
+
+ .filter-toggle-all {
+ background: transparent;
+ border: none;
+ color: ${(props) => props.theme.console.buttonColor};
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 500;
+ padding: 2px 4px;
+ border-radius: 2px;
+ transition: all 0.2s ease;
+
+ &:hover {
+ background: ${(props) => props.theme.console.buttonHoverBg};
+ }
+ }
+
+ .filter-dropdown-options {
+ padding: 4px 0;
+ }
+
+ .filter-option {
+ display: flex;
+ align-items: center;
+ padding: 6px 12px;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+
+ &:hover {
+ background: ${(props) => props.theme.console.optionHoverBg};
+ }
+
+ input[type="checkbox"] {
+ margin: 0 8px 0 0;
+ width: 14px;
+ height: 14px;
+ accent-color: ${(props) => props.theme.console.checkboxColor};
+ }
+ }
+
+ .filter-option-content {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex: 1;
+ }
+
+ .filter-option-label {
+ color: ${(props) => props.theme.console.optionLabelColor};
+ font-size: 12px;
+ font-weight: 400;
+ }
+
+ .filter-option-count {
+ color: ${(props) => props.theme.console.optionCountColor};
+ font-size: 11px;
+ font-weight: 400;
+ margin-left: auto;
+ }
+
+ .console-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: ${(props) => props.theme.console.emptyColor};
+ text-align: center;
+ gap: 8px;
+ padding: 40px 20px;
+
+ p {
+ margin: 0;
+ font-size: 14px;
+ font-weight: 500;
+ }
+
+ span {
+ font-size: 12px;
+ opacity: 0.7;
+ }
+ }
+
+ .logs-container {
+ padding: 8px 0;
+ }
+
+ .method-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 10px;
+ font-weight: 600;
+ color: white;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ min-width: 45px;
+ }
+
+ .log-entry {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 4px 16px;
+ font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ font-size: 12px;
+ line-height: 1.4;
+ border-left: 2px solid transparent;
+ transition: background-color 0.1s ease;
+
+ &:hover {
+ background: ${(props) => props.theme.console.logHoverBg};
+ }
+
+ &.error {
+ border-left-color: #f14c4c;
+
+ .log-level {
+ background: #f14c4c;
+ color: white;
+ }
+
+ .log-icon {
+ color: #f14c4c;
+ }
+ }
+
+ &.warn {
+ border-left-color: #ffcc02;
+
+ .log-level {
+ background: #ffcc02;
+ color: #000;
+ }
+
+ .log-icon {
+ color: #ffcc02;
+ }
+ }
+
+ &.info {
+ border-left-color: #0078d4;
+
+ .log-level {
+ background: #0078d4;
+ color: white;
+ }
+
+ .log-icon {
+ color: #0078d4;
+ }
+ }
+
+ &.debug {
+ border-left-color: #9b59b6;
+
+ .log-level {
+ background: #9b59b6;
+ color: white;
+ }
+
+ .log-icon {
+ color: #9b59b6;
+ }
+ }
+
+ &.log {
+ border-left-color: #6a6a6a;
+
+ .log-level {
+ background: #6a6a6a;
+ color: white;
+ }
+
+ .log-icon {
+ color: #6a6a6a;
+ }
+ }
+ }
+
+ .log-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ min-width: 120px;
+ }
+
+ .log-timestamp {
+ color: ${(props) => props.theme.console.timestampColor};
+ font-size: 11px;
+ font-weight: 400;
+ }
+
+ .log-level {
+ font-size: 9px;
+ font-weight: 600;
+ padding: 2px 4px;
+ border-radius: 2px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ }
+
+ .log-icon {
+ flex-shrink: 0;
+ }
+
+ .log-message {
+ color: ${(props) => props.theme.console.messageColor};
+ white-space: pre-wrap;
+ word-break: break-word;
+ flex: 1;
+
+ .log-object {
+ margin: 4px 0;
+ padding: 8px;
+ background: ${(props) => props.theme.console.headerBg};
+ border-radius: 4px;
+ border: 1px solid ${(props) => props.theme.console.border};
+
+ .react-json-view {
+ background: transparent !important;
+
+ .object-key-val {
+ font-size: 12px !important;
+ }
+
+ .object-key {
+ color: ${(props) => props.theme.console.messageColor} !important;
+ font-weight: 500 !important;
+ }
+
+ .object-value {
+ color: ${(props) => props.theme.console.messageColor} !important;
+ }
+
+ .string-value {
+ color: ${(props) => props.theme.colors?.text?.green || (props.theme.console.messageColor)} !important;
+ }
+
+ .number-value {
+ color: ${(props) => props.theme.colors?.text?.purple || (props.theme.console.messageColor)} !important;
+ }
+
+ .boolean-value {
+ color: ${(props) => props.theme.colors?.text?.yellow || (props.theme.console.messageColor)} !important;
+ }
+
+ .null-value {
+ color: ${(props) => props.theme.colors?.text?.danger || (props.theme.console.messageColor)} !important;
+ }
+
+ .object-size {
+ color: ${(props) => props.theme.console.timestampColor} !important;
+ }
+
+ .brace, .bracket {
+ color: ${(props) => props.theme.console.messageColor} !important;
+ }
+
+ .collapsed-icon, .expanded-icon {
+ color: ${(props) => props.theme.console.checkboxColor} !important;
+ }
+
+ .icon-container {
+ color: ${(props) => props.theme.console.checkboxColor} !important;
+ }
+
+ .click-to-expand, .click-to-collapse {
+ color: ${(props) => props.theme.console.checkboxColor} !important;
+ }
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Devtools/Console/index.js b/packages/bruno-app/src/components/Devtools/Console/index.js
new file mode 100644
index 000000000..e87e38d37
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/Console/index.js
@@ -0,0 +1,531 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { useSelector, useDispatch } from 'react-redux';
+import ReactJson from 'react-json-view';
+import { useTheme } from 'providers/Theme';
+import {
+ IconX,
+ IconTrash,
+ IconFilter,
+ IconAlertTriangle,
+ IconAlertCircle,
+ IconBug,
+ IconCode,
+ IconChevronDown,
+ IconTerminal2,
+ IconNetwork
+} from '@tabler/icons';
+import {
+ closeConsole,
+ clearLogs,
+ updateFilter,
+ toggleAllFilters,
+ setActiveTab,
+ clearDebugErrors,
+ updateNetworkFilter,
+ toggleAllNetworkFilters
+} from 'providers/ReduxStore/slices/logs';
+import NetworkTab from './NetworkTab';
+import RequestDetailsPanel from './RequestDetailsPanel';
+// import DebugTab from './DebugTab';
+import ErrorDetailsPanel from './ErrorDetailsPanel';
+import StyledWrapper from './StyledWrapper';
+
+const LogIcon = ({ type }) => {
+ const iconProps = { size: 16, strokeWidth: 1.5 };
+
+ switch (type) {
+ case 'error':
+ return ;
+ case 'warn':
+ return ;
+ case 'info':
+ return ;
+ // case 'debug':
+ // return ;
+ default:
+ return ;
+ }
+};
+
+const LogTimestamp = ({ timestamp }) => {
+ const date = new Date(timestamp);
+ const time = date.toLocaleTimeString('en-US', {
+ hour12: false,
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ fractionalSecondDigits: 3
+ });
+
+ return {time} ;
+};
+
+const LogMessage = ({ message, args }) => {
+ const { displayedTheme } = useTheme();
+
+ const formatMessage = (msg, originalArgs) => {
+ if (originalArgs && originalArgs.length > 0) {
+ return originalArgs.map((arg, index) => {
+ if (typeof arg === 'object' && arg !== null) {
+ return (
+
+
+
+ );
+ }
+ return String(arg);
+ });
+ }
+ return msg;
+ };
+
+ const formattedMessage = formatMessage(message, args);
+
+ return (
+
+ {Array.isArray(formattedMessage) ? formattedMessage.map((item, index) => (
+ {item}
+ )) : formattedMessage}
+
+ );
+};
+
+const FilterDropdown = ({ filters, logCounts, onFilterToggle, onToggleAll }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ const allFiltersEnabled = Object.values(filters).every(f => f);
+ const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ return (
+
+
setIsOpen(!isOpen)}
+ title="Filter logs by type"
+ >
+
+
+ {activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
+
+
+
+
+ {isOpen && (
+
+
+ Filter by Type
+ onToggleAll(!allFiltersEnabled)}
+ >
+ {allFiltersEnabled ? 'Hide All' : 'Show All'}
+
+
+
+
+ {Object.entries(filters).map(([filterType, enabled]) => (
+
+ onFilterToggle(filterType, e.target.checked)}
+ />
+
+
+ {filterType}
+ ({logCounts[filterType] || 0})
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+const NetworkFilterDropdown = ({ filters, requestCounts, onFilterToggle, onToggleAll }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef(null);
+
+ const allFiltersEnabled = Object.values(filters).every(f => f);
+ const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
+
+ const getMethodColor = (method) => {
+ switch (method?.toUpperCase()) {
+ case 'GET': return '#10b981';
+ case 'POST': return '#8b5cf6';
+ case 'PUT': return '#f59e0b';
+ case 'DELETE': return '#ef4444';
+ case 'PATCH': return '#06b6d4';
+ case 'HEAD': return '#6b7280';
+ case 'OPTIONS': return '#84cc16';
+ default: return '#6b7280';
+ }
+ };
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ return (
+
+
setIsOpen(!isOpen)}
+ title="Filter requests by method"
+ >
+
+
+ {activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
+
+
+
+
+ {isOpen && (
+
+
+ Filter by Method
+ onToggleAll(!allFiltersEnabled)}
+ >
+ {allFiltersEnabled ? 'Hide All' : 'Show All'}
+
+
+
+
+ {Object.entries(filters).map(([method, enabled]) => (
+
+ onFilterToggle(method, e.target.checked)}
+ />
+
+
+ {method}
+
+ {method}
+ ({requestCounts[method] || 0})
+
+
+ ))}
+
+
+ )}
+
+ );
+};
+
+const ConsoleTab = ({ logs, filters, logCounts, onFilterToggle, onToggleAll, onClearLogs }) => {
+ const logsEndRef = useRef(null);
+ const prevLogsCountRef = useRef(0);
+
+ useEffect(() => {
+ // Only scroll when new logs are added, not when switching tabs
+ if (logsEndRef.current && logs.length > prevLogsCountRef.current) {
+ logsEndRef.current.scrollIntoView({ behavior: 'auto' });
+ }
+ prevLogsCountRef.current = logs.length;
+ }, [logs]);
+
+ const filteredLogs = logs.filter(log => filters[log.type]);
+
+ return (
+
+
+ {filteredLogs.length === 0 ? (
+
+
+
No logs to display
+
Logs will appear here as your application runs
+
+ ) : (
+
+ {filteredLogs.map((log) => (
+
+ ))}
+
+
+ )}
+
+
+ );
+};
+
+const Console = () => {
+ const dispatch = useDispatch();
+ const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector(state => state.logs);
+ const collections = useSelector(state => state.collections.collections);
+ const consoleRef = useRef(null);
+
+ const logCounts = logs.reduce((counts, log) => {
+ counts[log.type] = (counts[log.type] || 0) + 1;
+ return counts;
+ }, {});
+
+ const allRequests = React.useMemo(() => {
+ const requests = [];
+
+ collections.forEach(collection => {
+ if (collection.timeline) {
+ collection.timeline
+ .filter(entry => entry.type === 'request')
+ .forEach(entry => {
+ requests.push({
+ ...entry,
+ collectionName: collection.name,
+ collectionUid: collection.uid
+ });
+ });
+ }
+ });
+
+ return requests.sort((a, b) => a.timestamp - b.timestamp);
+ }, [collections]);
+
+ const filteredLogs = logs.filter(log => filters[log.type]);
+ const filteredRequests = allRequests.filter(request => {
+ const method = request.data?.request?.method?.toUpperCase() || 'GET';
+ return networkFilters[method];
+ });
+
+ const requestCounts = allRequests.reduce((counts, request) => {
+ const method = request.data?.request?.method?.toUpperCase() || 'GET';
+ counts[method] = (counts[method] || 0) + 1;
+ return counts;
+ }, {});
+
+ const handleFilterToggle = (filterType, enabled) => {
+ dispatch(updateFilter({ filterType, enabled }));
+ };
+
+ const handleNetworkFilterToggle = (method, enabled) => {
+ dispatch(updateNetworkFilter({ method, enabled }));
+ };
+
+ const handleClearLogs = () => {
+ dispatch(clearLogs());
+ };
+
+ const handleClearDebugErrors = () => {
+ dispatch(clearDebugErrors());
+ };
+
+ const handlecloseConsole = () => {
+ dispatch(closeConsole());
+ };
+
+ const handleToggleAllFilters = (enabled) => {
+ dispatch(toggleAllFilters(enabled));
+ };
+
+ const handleToggleAllNetworkFilters = (enabled) => {
+ dispatch(toggleAllNetworkFilters(enabled));
+ };
+
+ const handleTabChange = (tab) => {
+ dispatch(setActiveTab(tab));
+ };
+
+ const renderTabContent = () => {
+ switch (activeTab) {
+ case 'console':
+ return (
+
+ );
+ case 'network':
+ return ;
+ // case 'debug':
+ // return ;
+ default:
+ return (
+
+ );
+ }
+ };
+
+ const renderTabControls = () => {
+ switch (activeTab) {
+ case 'console':
+ return (
+
+ );
+ case 'network':
+ return (
+
+ );
+ // case 'debug':
+ // return (
+ //
+ //
+ // {debugErrors.length > 0 && (
+ //
+ //
+ //
+ // )}
+ //
+ //
+ // );
+ default:
+ return null;
+ }
+ };
+
+
+
+ return (
+
+
+
+
+
+ handleTabChange('console')}
+ >
+
+ Console
+
+
+ handleTabChange('network')}
+ >
+
+ Network
+
+
+ {/* handleTabChange('debug')}
+ >
+
+ Debug
+ */}
+
+
+
+ {renderTabControls()}
+
+
+
+
+
+
+
+ {activeTab === 'network' && selectedRequest ? (
+
+
+ {renderTabContent()}
+
+
+
+ ) : activeTab === 'debug' && selectedError ? (
+
+
+ {renderTabContent()}
+
+
+
+ ) : (
+ renderTabContent()
+ )}
+
+
+ );
+};
+
+export default Console;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Devtools/index.js b/packages/bruno-app/src/components/Devtools/index.js
new file mode 100644
index 000000000..b943e4502
--- /dev/null
+++ b/packages/bruno-app/src/components/Devtools/index.js
@@ -0,0 +1,88 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import Console from './Console';
+
+const MIN_DEVTOOLS_HEIGHT = 150;
+const MAX_DEVTOOLS_HEIGHT = window.innerHeight * 0.7;
+const DEFAULT_DEVTOOLS_HEIGHT = 300;
+
+const Devtools = ({ mainSectionRef }) => {
+ const isDevtoolsOpen = useSelector((state) => state.logs.isConsoleOpen);
+ const [devtoolsHeight, setDevtoolsHeight] = useState(DEFAULT_DEVTOOLS_HEIGHT);
+ const [isResizingDevtools, setIsResizingDevtools] = useState(false);
+
+ const handleDevtoolsResizeStart = useCallback((e) => {
+ e.preventDefault();
+ setIsResizingDevtools(true);
+ }, []);
+
+ const handleDevtoolsResize = useCallback((e) => {
+ if (!isResizingDevtools || !mainSectionRef.current) return;
+
+ const windowHeight = window.innerHeight;
+ const statusBarHeight = 22;
+ const mouseY = e.clientY;
+
+ // Calculate new devtools height - expanding upward from bottom
+ const newHeight = windowHeight - mouseY - statusBarHeight;
+ const clampedHeight = Math.min(MAX_DEVTOOLS_HEIGHT, Math.max(MIN_DEVTOOLS_HEIGHT, newHeight));
+ setDevtoolsHeight(clampedHeight);
+
+ // Update main section height
+ if (mainSectionRef.current) {
+ mainSectionRef.current.style.height = `calc(100vh - 22px - ${clampedHeight}px)`;
+ }
+ }, [isResizingDevtools, mainSectionRef]);
+
+ const handleDevtoolsResizeEnd = useCallback(() => {
+ setIsResizingDevtools(false);
+ }, []);
+
+ useEffect(() => {
+ if (isResizingDevtools) {
+ document.addEventListener('mousemove', handleDevtoolsResize);
+ document.addEventListener('mouseup', handleDevtoolsResizeEnd);
+ document.body.style.userSelect = 'none';
+
+ return () => {
+ document.removeEventListener('mousemove', handleDevtoolsResize);
+ document.removeEventListener('mouseup', handleDevtoolsResizeEnd);
+ document.body.style.userSelect = '';
+ };
+ }
+ }, [isResizingDevtools, handleDevtoolsResize, handleDevtoolsResizeEnd]);
+
+ // Set initial height
+ useEffect(() => {
+ if (mainSectionRef.current && isDevtoolsOpen) {
+ mainSectionRef.current.style.height = `calc(100vh - 22px - ${devtoolsHeight}px)`;
+ }
+ }, [isDevtoolsOpen, devtoolsHeight, mainSectionRef]);
+
+ if (!isDevtoolsOpen) {
+ return null;
+ }
+
+ return (
+ <>
+ e.target.style.backgroundColor = '#0078d4'}
+ onMouseLeave={(e) => e.target.style.backgroundColor = isResizingDevtools ? '#0078d4' : 'transparent'}
+ />
+
+
+
+ >
+ );
+};
+
+export default Devtools;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Documentation/StyledWrapper.js b/packages/bruno-app/src/components/Documentation/StyledWrapper.js
index f159d94dc..af80d4c08 100644
--- a/packages/bruno-app/src/components/Documentation/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Documentation/StyledWrapper.js
@@ -3,7 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
.editing-mode {
cursor: pointer;
- color: ${(props) => props.theme.colors.text.yellow};
}
`;
diff --git a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js
index f784cf527..3da7f2d34 100644
--- a/packages/bruno-app/src/components/Dropdown/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Dropdown/StyledWrapper.js
@@ -16,6 +16,7 @@ const Wrapper = styled.div`
border-radius: 3px;
max-height: 90vh;
overflow-y: auto;
+ max-width: unset !important;
.tippy-content {
padding-left: 0;
diff --git a/packages/bruno-app/src/components/Dropdown/index.js b/packages/bruno-app/src/components/Dropdown/index.js
index 3deb0e849..c4eccce64 100644
--- a/packages/bruno-app/src/components/Dropdown/index.js
+++ b/packages/bruno-app/src/components/Dropdown/index.js
@@ -2,7 +2,7 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
-const Dropdown = ({ icon, children, onCreate, placement, transparent }) => {
+const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }) => {
return (
{
interactive={true}
trigger="click"
appendTo="parent"
+ {...props}
>
{icon}
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js
index c5130d038..4f3dcb5ba 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/CreateEnvironment/index.js
@@ -6,6 +6,7 @@ import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
+import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ collection, onClose }) => {
const dispatch = useDispatch();
@@ -23,7 +24,11 @@ const CreateEnvironment = ({ collection, onClose }) => {
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'Must be at least 1 character')
- .max(50, 'Must be 50 characters or less')
+ .max(255, 'Must be 255 characters or less')
+ .test('is-valid-filename', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.required('Name is required')
.test('duplicate-name', 'Environment already exists', validateEnvironmentName)
}),
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/constants.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/constants.js
new file mode 100644
index 000000000..c7dbe0ef5
--- /dev/null
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/constants.js
@@ -0,0 +1,11 @@
+const sensitiveFields = [
+ 'request.auth.oauth2.clientSecret',
+ 'request.auth.basic.password',
+ 'request.auth.digest.password',
+ 'request.auth.wsse.password',
+ 'request.auth.ntlm.password',
+ 'request.auth.awsv4.secretAccessKey',
+ 'request.auth.bearer.token'
+];
+
+export { sensitiveFields };
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
index ab2ea7691..5ba3b0797 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/EnvironmentList/EnvironmentDetails/EnvironmentVariables/index.js
@@ -1,8 +1,9 @@
-import React, { useRef, useEffect } from 'react';
+import React, { useRef, useEffect, useMemo } from 'react';
import cloneDeep from 'lodash/cloneDeep';
+import { get } from 'lodash';
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
@@ -13,11 +14,64 @@ import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
+import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'utils/collections';
+import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
+ const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
+
+ let _collection = cloneDeep(collection);
+
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
+ _collection.globalEnvironmentVariables = globalEnvironmentVariables;
+
+ const nonSecretSensitiveVarUsageMap = useMemo(() => {
+ const result = {};
+ if (!collection || !environment?.variables) {
+ return result;
+ }
+ const nonSecretVars = environment.variables.filter((v) => v.enabled && !v.secret && v.name);
+ if (!nonSecretVars.length) {
+ return result;
+ }
+ const varNames = new Set(nonSecretVars.map((v) => v.name));
+
+ const checkSensitiveField = (obj, fieldPath) => {
+ const value = get(obj, fieldPath);
+ if (typeof value === 'string') {
+ varNames.forEach((varName) => {
+ if (new RegExp(`\{\{\s*${varName}\s*\}\}`).test(value)) {
+ result[varName] = true;
+ }
+ });
+ }
+ };
+
+ const getObjectToProcess = (item) => {
+ if (isItemARequest(item)) {
+ return item.draft || item;
+ }
+ return item.root;
+ };
+
+ const collectionObj = getObjectToProcess(collection);
+ sensitiveFields.forEach((fieldPath) => {
+ checkSensitiveField(collectionObj, fieldPath);
+ });
+
+ const items = flattenItems(collection.items || []);
+ items.forEach((item) => {
+ const objToProcess = getObjectToProcess(item);
+ sensitiveFields.forEach((fieldPath) => {
+ checkSensitiveField(objToProcess, fieldPath);
+ });
+ });
+ return result;
+ }, [collection, environment]);
const formik = useFormik({
enableReinitialize: true,
@@ -54,6 +108,8 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
}
});
+ const hasSensitiveUsage = (name) => !!nonSecretSensitiveVarUsageMap[name];
+
// Effect to track modifications.
React.useEffect(() => {
setIsModified(formik.dirty);
@@ -156,17 +212,23 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
-
+
formik.setFieldValue(`${index}.value`, newValue, true)}
/>
+ {!variable.secret && hasSensitiveUsage(variable.name) && (
+
+ )}
{
.then(() => {
toast.success('Environment imported successfully');
})
- .catch(() => toast.error('An error occurred while importing the environment'));
+ .catch((error) => {
+ toast.error('An error occurred while importing the environment');
+ console.error(error);
+ });
});
})
.then(() => {
diff --git a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js
index ca025003c..de50ad92b 100644
--- a/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js
+++ b/packages/bruno-app/src/components/Environments/EnvironmentSettings/ManageSecrets/index.js
@@ -9,7 +9,7 @@ const ManageSecrets = ({ onClose }) => {
In any collection, there are secrets that need to be managed.
These secrets can be anything such as API keys, passwords, or tokens.
-
Bruno offers two approaches to manage secrets in collections.
+
Bruno offers three approaches to manage secrets in collections.
Read more about it in our{' '}
{
const dispatch = useDispatch();
@@ -18,7 +19,11 @@ const RenameEnvironment = ({ onClose, environment, collection }) => {
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'Must be 255 characters or less')
+ .test('is-valid-filename', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.required('name is required')
}),
onSubmit: (values) => {
diff --git a/packages/bruno-app/src/components/ErrorCapture/index.js b/packages/bruno-app/src/components/ErrorCapture/index.js
new file mode 100644
index 000000000..8a811276d
--- /dev/null
+++ b/packages/bruno-app/src/components/ErrorCapture/index.js
@@ -0,0 +1,147 @@
+import React, { Component, useEffect } from 'react';
+import { useDispatch } from 'react-redux';
+import { addDebugError } from 'providers/ReduxStore/slices/logs';
+
+class ErrorBoundary extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(error) {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error, errorInfo) {
+ if (this.props.onError) {
+ this.props.onError({
+ message: error.message,
+ stack: error.stack,
+ error: error,
+ timestamp: new Date().toISOString()
+ });
+ }
+
+ setTimeout(() => {
+ this.setState({ hasError: false });
+ }, 100);
+ }
+
+ render() {
+ return this.props.children;
+ }
+}
+
+const serializeArgs = (args) => {
+ return args.map(arg => {
+ try {
+ if (arg === null) return 'null';
+ if (arg === undefined) return 'undefined';
+ if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
+ return arg;
+ }
+ if (arg instanceof Error) {
+ return {
+ __type: 'Error',
+ name: arg.name,
+ message: arg.message,
+ stack: arg.stack
+ };
+ }
+ if (typeof arg === 'object') {
+ try {
+ return JSON.parse(JSON.stringify(arg));
+ } catch {
+ return String(arg);
+ }
+ }
+ return String(arg);
+ } catch (e) {
+ return '[Unserializable]';
+ }
+ });
+};
+
+// Helper function to extract file and line info from stack trace
+const extractFileInfo = (stack) => {
+ if (!stack) return { filename: null, lineno: null, colno: null };
+
+ try {
+ const lines = stack.split('\n');
+ for (let line of lines) {
+ if (line.includes('ErrorCapture') || line.trim() === 'Error') continue;
+
+ const match = line.match(/(?:at\s+.*?\s+)?\(?([^)]+):(\d+):(\d+)\)?/);
+ if (match) {
+ return {
+ filename: match[1],
+ lineno: parseInt(match[2]),
+ colno: parseInt(match[3])
+ };
+ }
+ }
+ } catch (e) {
+ // Ignore parsing errors
+ }
+
+ return { filename: null, lineno: null, colno: null };
+};
+
+const useGlobalErrorCapture = () => {
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ const originalConsoleError = console.error;
+
+ console.error = (...args) => {
+ const currentStack = new Error().stack;
+
+ originalConsoleError.apply(console, args);
+
+ if (currentStack && currentStack.includes('useIpcEvents.js')) {
+ return;
+ }
+
+ const errorMessage = args.join(' ');
+ if (errorMessage.includes('removeConsoleLogListener')) {
+ return;
+ }
+
+ const { filename, lineno, colno } = extractFileInfo(currentStack);
+
+ const serializedArgs = serializeArgs(args);
+
+ dispatch(addDebugError({
+ message: errorMessage,
+ stack: currentStack,
+ filename: filename,
+ lineno: lineno,
+ colno: colno,
+ args: serializedArgs,
+ timestamp: new Date().toISOString()
+ }));
+ };
+
+ return () => {
+ console.error = originalConsoleError;
+ };
+ }, [dispatch]);
+};
+
+const ErrorCapture = ({ children }) => {
+ const dispatch = useDispatch();
+
+ useGlobalErrorCapture();
+
+ const handleReactError = (errorData) => {
+ dispatch(addDebugError(errorData));
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default ErrorCapture;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/FilePickerEditor/index.js b/packages/bruno-app/src/components/FilePickerEditor/index.js
index 797771bbb..26969dde3 100644
--- a/packages/bruno-app/src/components/FilePickerEditor/index.js
+++ b/packages/bruno-app/src/components/FilePickerEditor/index.js
@@ -1,15 +1,13 @@
import React from 'react';
-import path from 'path';
+import path from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import { IconX } from '@tabler/icons';
import { isWindowsOS } from 'utils/common/platform';
-import slash from 'utils/common/slash';
-const FilePickerEditor = ({ value, onChange, collection }) => {
- value = value || [];
+const FilePickerEditor = ({ value, onChange, collection, isSingleFilePicker = false }) => {
const dispatch = useDispatch();
- const filenames = value
+ const filenames = (isSingleFilePicker ? [value] : value || [])
.filter((v) => v != null && v != '')
.map((v) => {
const separator = isWindowsOS() ? '\\' : '/';
@@ -20,7 +18,7 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
const title = filenames.map((v) => `- ${v}`).join('\n');
const browse = () => {
- dispatch(browseFiles())
+ dispatch(browseFiles([], [!isSingleFilePicker ? "multiSelections": ""]))
.then((filePaths) => {
// If file is in the collection's directory, then we use relative path
// Otherwise, we use the absolute path
@@ -28,13 +26,13 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
- return path.relative(slash(collectionDir), slash(filePath));
+ return path.relative(collectionDir, filePath);
}
return filePath;
});
- onChange(filePaths);
+ onChange(isSingleFilePicker ? filePaths[0] : filePaths);
})
.catch((error) => {
console.error(error);
@@ -42,14 +40,14 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
};
const clear = () => {
- onChange([]);
+ onChange(isSingleFilePicker ? '' : []);
};
const renderButtonText = (filenames) => {
if (filenames.length == 1) {
return filenames[0];
}
- return filenames.length + ' files selected';
+ return filenames.length + ' file(s) selected';
};
return filenames.length > 0 ? (
@@ -66,9 +64,9 @@ const FilePickerEditor = ({ value, onChange, collection }) => {
) : (
- Select Files
+ {isSingleFilePicker ? 'Select File' : 'Select Files'}
);
};
-export default FilePickerEditor;
+export default FilePickerEditor;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js
similarity index 64%
rename from packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/StyledWrapper.js
rename to packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js
index 856f35b9b..ba243d42b 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/StyledWrapper.js
+++ b/packages/bruno-app/src/components/FolderSettings/Auth/StyledWrapper.js
@@ -11,6 +11,12 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
+ .inherit-mode-text {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+ .auth-mode-label {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
`;
-export default Wrapper;
+export default Wrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/FolderSettings/Auth/index.js b/packages/bruno-app/src/components/FolderSettings/Auth/index.js
new file mode 100644
index 000000000..2c852af4b
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/Auth/index.js
@@ -0,0 +1,229 @@
+import React from 'react';
+import get from 'lodash/get';
+import StyledWrapper from './StyledWrapper';
+import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
+import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
+import { updateFolderAuth } from 'providers/ReduxStore/slices/collections';
+import { useDispatch } from 'react-redux';
+import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
+import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
+import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
+import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
+import AuthMode from '../AuthMode';
+import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
+import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
+import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
+import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
+import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
+import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
+import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
+import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
+
+const GrantTypeComponentMap = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+
+ const save = () => {
+ dispatch(saveFolderRoot(collection.uid, folder.uid));
+ };
+
+ let request = get(folder, 'root.request', {});
+ const grantType = get(request, 'auth.oauth2.grantType', 'authorization_code');
+
+ switch (grantType) {
+ case 'password':
+ return ;
+ case 'authorization_code':
+ return ;
+ case 'client_credentials':
+ return ;
+ case 'implicit':
+ return ;
+ default:
+ return TBD
;
+ }
+};
+
+const Auth = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ let request = get(folder, 'root.request', {});
+ const authMode = get(folder, 'root.request.auth.mode');
+
+ const getTreePathFromCollectionToFolder = (collection, _folder) => {
+ let path = [];
+ let item = findItemInCollection(collection, _folder?.uid);
+ while (item) {
+ path.unshift(item);
+ item = findParentItemInCollection(collection, item?.uid);
+ }
+ return path;
+ };
+
+ const getEffectiveAuthSource = () => {
+ if (authMode !== 'inherit') return null;
+
+ const collectionAuth = get(collection, 'root.request.auth');
+ let effectiveSource = {
+ type: 'collection',
+ name: 'Collection',
+ auth: collectionAuth
+ };
+
+ // Get path from collection to current folder
+ const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
+
+ // Check parent folders to find closest auth configuration
+ // Skip the last item which is the current folder
+ for (let i = 0; i < folderTreePath.length - 1; i++) {
+ const parentFolder = folderTreePath[i];
+ if (parentFolder.type === 'folder') {
+ const folderAuth = get(parentFolder, 'root.request.auth');
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
+ effectiveSource = {
+ type: 'folder',
+ name: parentFolder.name,
+ auth: folderAuth
+ };
+ break;
+ }
+ }
+ }
+
+ return effectiveSource;
+ };
+
+ const handleSave = () => {
+ dispatch(saveFolderRoot(collection.uid, folder.uid));
+ };
+
+ const getAuthView = () => {
+ switch (authMode) {
+ case 'basic': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'bearer': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'digest': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'ntlm': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'wsse': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'apikey': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'awsv4': {
+ return (
+ handleSave()}
+ />
+ );
+ }
+ case 'oauth2': {
+ return (
+ <>
+
+
+ >
+ );
+ }
+ case 'inherit': {
+ const source = getEffectiveAuthSource();
+ return (
+ <>
+
+
Auth inherited from {source.name}:
+
{humanizeRequestAuthMode(source.auth?.mode)}
+
+ >
+ );
+ }
+ case 'none': {
+ return null;
+ }
+ default:
+ return null;
+ }
+ };
+
+
+ return (
+
+
+ Configures authentication for the entire folder. This applies to all requests using the{' '}
+ Inherit option in the Auth tab.
+
+
+ {getAuthView()}
+
+
+ Save
+
+
+
+ );
+};
+
+export default Auth;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js
new file mode 100644
index 000000000..2a42257eb
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/StyledWrapper.js
@@ -0,0 +1,16 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .auth-mode-selector {
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.8125rem;
+ }
+
+ .auth-mode-label {
+ color: ${({ theme }) => theme.colors.text};
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js
new file mode 100644
index 000000000..36377973a
--- /dev/null
+++ b/packages/bruno-app/src/components/FolderSettings/AuthMode/index.js
@@ -0,0 +1,134 @@
+import React, { useRef, forwardRef } from 'react';
+import get from 'lodash/get';
+import { IconCaretDown } from '@tabler/icons';
+import Dropdown from 'components/Dropdown';
+import { useDispatch } from 'react-redux';
+import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections';
+import { humanizeRequestAuthMode } from 'utils/collections';
+import StyledWrapper from './StyledWrapper';
+
+const AuthMode = ({ collection, folder }) => {
+ const dispatch = useDispatch();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+ const authMode = get(folder, 'root.request.auth.mode');
+
+ const Icon = forwardRef((props, ref) => {
+ return (
+
+ {humanizeRequestAuthMode(authMode)}
+
+ );
+ });
+
+ const onModeChange = (value) => {
+ dispatch(
+ updateFolderAuthMode({
+ mode: value,
+ collectionUid: collection.uid,
+ folderUid: folder.uid
+ })
+ );
+ };
+
+ return (
+
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('awsv4');
+ }}
+ >
+ AWS Sig v4
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('basic');
+ }}
+ >
+ Basic Auth
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('bearer');
+ }}
+ >
+ Bearer Token
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('digest');
+ }}
+ >
+ Digest Auth
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('ntlm');
+ }}
+ >
+ NTLM Auth
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('oauth2');
+ }}
+ >
+ OAuth 2.0
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('wsse');
+ }}
+ >
+ WSSE Auth
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('apikey');
+ }}
+ >
+ API Key
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('inherit');
+ }}
+ >
+ Inherit
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('none');
+ }}
+ >
+ No Auth
+
+
+
+
+ );
+};
+
+export default AuthMode;
diff --git a/packages/bruno-app/src/components/FolderSettings/Headers/index.js b/packages/bruno-app/src/components/FolderSettings/Headers/index.js
index 0f6e05f1f..79f22a0b8 100644
--- a/packages/bruno-app/src/components/FolderSettings/Headers/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/Headers/index.js
@@ -1,20 +1,31 @@
-import React from 'react';
+import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useTheme } from 'providers/Theme';
-import { addFolderHeader, updateFolderHeader, deleteFolderHeader } from 'providers/ReduxStore/slices/collections';
+import { addFolderHeader, updateFolderHeader, deleteFolderHeader, setFolderHeaders } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
+import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
+import BulkEditor from 'components/BulkEditor/index';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = get(folder, 'root.request.headers', []);
+ const [isBulkEditMode, setIsBulkEditMode] = useState(false);
+
+ const toggleBulkEditMode = () => {
+ setIsBulkEditMode(!isBulkEditMode);
+ };
+
+ const handleBulkHeadersChange = (newHeaders) => {
+ dispatch(setFolderHeaders({ collectionUid: collection.uid, folderUid: folder.uid, headers: newHeaders }));
+ };
const addHeader = () => {
dispatch(
@@ -61,6 +72,22 @@ const Headers = ({ collection, folder }) => {
);
};
+ if (isBulkEditMode) {
+ return (
+
+
+ Request headers that will be sent with every request inside this folder.
+
+
+
+ );
+ }
+
return (
@@ -117,6 +144,7 @@ const Headers = ({ collection, folder }) => {
}
collection={collection}
item={folder}
+ autocomplete={MimeTypes}
/>
@@ -139,9 +167,14 @@ const Headers = ({ collection, folder }) => {
: null}
-
- + Add Header
-
+
+
+ + Add Header
+
+
+ Bulk Edit
+
+
diff --git a/packages/bruno-app/src/components/FolderSettings/Script/index.js b/packages/bruno-app/src/components/FolderSettings/Script/index.js
index 628fa5cb5..5c3ca5b0d 100644
--- a/packages/bruno-app/src/components/FolderSettings/Script/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/Script/index.js
@@ -55,6 +55,7 @@ const Script = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
+ showHintsFor={['req', 'bru']}
/>
@@ -68,6 +69,7 @@ const Script = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
+ showHintsFor={['req', 'res', 'bru']}
/>
diff --git a/packages/bruno-app/src/components/FolderSettings/Tests/index.js b/packages/bruno-app/src/components/FolderSettings/Tests/index.js
index 8854b06cd..ae20a3b8e 100644
--- a/packages/bruno-app/src/components/FolderSettings/Tests/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/Tests/index.js
@@ -38,6 +38,7 @@ const Tests = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
+ showHintsFor={['req', 'res', 'bru']}
/>
diff --git a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js
index 17d79629e..b0815c018 100644
--- a/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/Vars/VarsTable/index.js
@@ -88,7 +88,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
Expr
-
+
)}
diff --git a/packages/bruno-app/src/components/FolderSettings/index.js b/packages/bruno-app/src/components/FolderSettings/index.js
index 4e1eba753..61170e5ca 100644
--- a/packages/bruno-app/src/components/FolderSettings/index.js
+++ b/packages/bruno-app/src/components/FolderSettings/index.js
@@ -8,15 +8,9 @@ import Tests from './Tests';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars';
import Documentation from './Documentation';
-import DotIcon from 'components/Icons/Dot';
-
-const ContentIndicator = () => {
- return (
-
-
-
- );
-};
+import Auth from './Auth';
+import StatusDot from 'components/StatusDot';
+import get from 'lodash/get';
const FolderSettings = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -26,7 +20,7 @@ const FolderSettings = ({ collection, folder }) => {
tab = folderLevelSettingsSelectedTab[folder?.uid];
}
- const folderRoot = collection?.items.find((item) => item.uid === folder?.uid)?.root;
+ const folderRoot = folder?.root;
const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
const hasTests = folderRoot?.request?.tests;
@@ -37,6 +31,9 @@ const FolderSettings = ({ collection, folder }) => {
const responseVars = folderRoot?.request?.vars?.res || [];
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
+ const auth = get(folderRoot, 'request.auth.mode');
+ const hasAuth = auth && auth !== 'none';
+
const setTab = (tab) => {
dispatch(
updatedFolderSettingsSelectedTab({
@@ -61,6 +58,9 @@ const FolderSettings = ({ collection, folder }) => {
case 'vars': {
return
;
}
+ case 'auth': {
+ return
;
+ }
case 'docs': {
return
;
}
@@ -74,7 +74,7 @@ const FolderSettings = ({ collection, folder }) => {
};
return (
-
+
setTab('headers')}>
@@ -83,21 +83,25 @@ const FolderSettings = ({ collection, folder }) => {
setTab('script')}>
Script
- {hasScripts && }
+ {hasScripts && }
setTab('test')}>
Test
- {hasTests && }
+ {hasTests && }
setTab('vars')}>
Vars
{activeVarsCount > 0 && {activeVarsCount} }
+
setTab('auth')}>
+ Auth
+ {hasAuth && }
+
setTab('docs')}>
Docs
-
+
);
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js
index 5bf55809c..b0042bcbf 100644
--- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSelector/index.js
@@ -81,7 +81,10 @@ const EnvironmentSelector = () => {
No Environment
-
+
{
+ handleSettingsIconClick();
+ dropdownTippyRef.current.hide();
+ }}>
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js
index 3bf8af65e..d9eb83191 100644
--- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment/index.js
@@ -6,6 +6,7 @@ import { useDispatch, useSelector } from 'react-redux';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ onClose }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
@@ -25,7 +26,11 @@ const CreateEnvironment = ({ onClose }) => {
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'Must be at least 1 character')
- .max(50, 'Must be 50 characters or less')
+ .max(255, 'Must be 255 characters or less')
+ .test('is-valid-filename', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.required('Name is required')
.test('duplicate-name', 'Global Environment already exists', validateEnvironmentName)
}),
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment/index.js
index 99900f740..f28ce36e1 100644
--- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment/index.js
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment/index.js
@@ -25,16 +25,14 @@ const ImportEnvironment = ({ onClose }) => {
}
)
.map((environment) => {
- let variables = environment?.variables?.map(v => ({
- ...v,
- uid: uuid(),
- type: 'text'
- }));
- dispatch(addGlobalEnvironment({ name: environment.name, variables }))
+ dispatch(addGlobalEnvironment({ name: environment.name, variables: environment.variables }))
.then(() => {
toast.success('Global Environment imported successfully');
})
- .catch(() => toast.error('An error occurred while importing the environment'));
+ .catch((error) => {
+ toast.error('An error occurred while importing the environment');
+ console.error(error);
+ });
});
})
.then(() => {
diff --git a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js
index ff1809383..581abd27c 100644
--- a/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js
+++ b/packages/bruno-app/src/components/GlobalEnvironments/EnvironmentSettings/RenameEnvironment/index.js
@@ -3,10 +3,10 @@ import Portal from 'components/Portal/index';
import Modal from 'components/Modal/index';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
-import { renameEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import * as Yup from 'yup';
import { useDispatch } from 'react-redux';
import { renameGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
+import { validateName, validateNameError } from 'utils/common/regex';
const RenameEnvironment = ({ onClose, environment }) => {
const dispatch = useDispatch();
@@ -19,7 +19,11 @@ const RenameEnvironment = ({ onClose, environment }) => {
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'Must be 255 characters or less')
+ .test('is-valid-filename', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.required('name is required')
}),
onSubmit: (values) => {
diff --git a/packages/bruno-app/src/components/Help/StyledWrapper.js b/packages/bruno-app/src/components/Help/StyledWrapper.js
new file mode 100644
index 000000000..f4a69fe40
--- /dev/null
+++ b/packages/bruno-app/src/components/Help/StyledWrapper.js
@@ -0,0 +1,11 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ font-weight: 400;
+ font-size: 0.75rem;
+ background-color: ${props => props.theme.infoTip.bg};
+ border: 1px solid ${props => props.theme.infoTip.border};
+ box-shadow: ${props => props.theme.infoTip.boxShadow};
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/Help/index.js b/packages/bruno-app/src/components/Help/index.js
new file mode 100644
index 000000000..6d3f40f87
--- /dev/null
+++ b/packages/bruno-app/src/components/Help/index.js
@@ -0,0 +1,40 @@
+/**
+ * The InfoTip components needs to be nuked
+ * This component will be the future replacement
+ * We should allow icon and placement props to be passed in
+ */
+
+import React, { useState } from 'react';
+import HelpIcon from 'components/Icons/Help';
+import StyledWrapper from './StyledWrapper';
+
+const Help = ({ children, width = 200 }) => {
+ const [showTooltip, setShowTooltip] = useState(false);
+
+ return (
+
+ setShowTooltip(true)}
+ onMouseLeave={() => setShowTooltip(false)}
+ >
+
+
+ {showTooltip && (
+
+ {children}
+
+ )}
+
+ );
+};
+
+export default Help;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Icons/Dot/index.js b/packages/bruno-app/src/components/Icons/Dot/index.js
index e889bea48..a69a375b1 100644
--- a/packages/bruno-app/src/components/Icons/Dot/index.js
+++ b/packages/bruno-app/src/components/Icons/Dot/index.js
@@ -8,7 +8,7 @@ const DotIcon = ({ width }) => {
className='inline-block'
>
-
+
);
};
diff --git a/packages/bruno-app/src/components/Icons/Grpc/index.js b/packages/bruno-app/src/components/Icons/Grpc/index.js
new file mode 100644
index 000000000..1424ce288
--- /dev/null
+++ b/packages/bruno-app/src/components/Icons/Grpc/index.js
@@ -0,0 +1,93 @@
+import React from 'react';
+
+// UNARY - Single request, single response (Blue)
+export const IconGrpcUnary = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
+
+
+ {/* Request arrow (top) - right */}
+
+
+ {/* Response arrow (bottom) - left */}
+
+
+
+);
+
+// CLIENT_STREAMING - Streaming request, single response (Purple)
+export const IconGrpcClientStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
+
+
+ {/* Request arrow (top) - right with double heads */}
+
+
+
+ {/* Response arrow (bottom) - left */}
+
+
+
+);
+
+// SERVER_STREAMING - Single request, streaming response (Green)
+export const IconGrpcServerStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
+
+
+ {/* Request arrow (top) - right */}
+
+
+ {/* Response arrow (bottom) - left with double heads */}
+
+
+
+
+);
+
+// BIDI_STREAMING - Streaming request, streaming response (Orange)
+export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className = '' }) => (
+
+
+ {/* Request arrow (top) - right with double heads */}
+
+
+
+ {/* Response arrow (bottom) - left with double heads */}
+
+
+
+
+);
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Icons/Help/index.js b/packages/bruno-app/src/components/Icons/Help/index.js
new file mode 100644
index 000000000..95c8710af
--- /dev/null
+++ b/packages/bruno-app/src/components/Icons/Help/index.js
@@ -0,0 +1,20 @@
+import React from 'react';
+
+const HelpIcon = ({ size = 14 }) => {
+ return (
+
+
+
+
+ )
+}
+
+export default HelpIcon;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Icons/IconSidebarToggle/index.js b/packages/bruno-app/src/components/Icons/IconSidebarToggle/index.js
new file mode 100644
index 000000000..bad3221fb
--- /dev/null
+++ b/packages/bruno-app/src/components/Icons/IconSidebarToggle/index.js
@@ -0,0 +1,28 @@
+import React from 'react';
+
+const IconSidebarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
+ return (
+
+
+
+
+ {!collapsed && (
+
+ )}
+
+ );
+};
+
+export default IconSidebarToggle;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Icons/OpenAPILogo/index.js b/packages/bruno-app/src/components/Icons/OpenAPILogo/index.js
new file mode 100644
index 000000000..b472b3d8c
--- /dev/null
+++ b/packages/bruno-app/src/components/Icons/OpenAPILogo/index.js
@@ -0,0 +1,104 @@
+const OpenApiLogo = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default OpenApiLogo;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/InfoTip/index.js b/packages/bruno-app/src/components/InfoTip/index.js
index 97eb63d4d..2af6034ed 100644
--- a/packages/bruno-app/src/components/InfoTip/index.js
+++ b/packages/bruno-app/src/components/InfoTip/index.js
@@ -1,7 +1,7 @@
import React from 'react';
import { Tooltip as ReactInfoTip } from 'react-tooltip';
-const InfoTip = ({ text, infotipId }) => {
+const InfoTip = ({ html: _ignored, infotipId, ...props }) => {
return (
<>
{
-
+
>
);
};
diff --git a/packages/bruno-app/src/components/MarkDown/StyledWrapper.js b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js
index fa1269e14..a7a174a69 100644
--- a/packages/bruno-app/src/components/MarkDown/StyledWrapper.js
+++ b/packages/bruno-app/src/components/MarkDown/StyledWrapper.js
@@ -9,21 +9,20 @@ const StyledMarkdownBodyWrapper = styled.div`
box-sizing: border-box;
height: 100%;
margin: 0 auto;
- padding-top: 0.5rem;
font-size: 0.875rem;
h1 {
margin: 0.67em 0;
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
- font-size: 1.3;
+ font-size: 1.3em;
border-bottom: 1px solid var(--color-border-muted);
}
h2 {
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
- font-size: 1.2;
+ font-size: 1.2em;
border-bottom: 1px solid var(--color-border-muted);
}
@@ -80,12 +79,6 @@ const StyledMarkdownBodyWrapper = styled.div`
}
}
}
-
- @media (max-width: 767px) {
- .markdown-body {
- padding: 15px;
- }
- }
`;
export default StyledMarkdownBodyWrapper;
diff --git a/packages/bruno-app/src/components/Modal/index.js b/packages/bruno-app/src/components/Modal/index.js
index 0b44b928b..99bf1f89d 100644
--- a/packages/bruno-app/src/components/Modal/index.js
+++ b/packages/bruno-app/src/components/Modal/index.js
@@ -16,7 +16,7 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
);
-const ModalContent = ({ children }) =>
{children}
;
+const ModalContent = ({ children }) =>
{children}
;
const ModalFooter = ({
confirmText,
diff --git a/packages/bruno-app/src/components/MultiLineEditor/index.js b/packages/bruno-app/src/components/MultiLineEditor/index.js
index a44caf4ba..bd4fc60fe 100644
--- a/packages/bruno-app/src/components/MultiLineEditor/index.js
+++ b/packages/bruno-app/src/components/MultiLineEditor/index.js
@@ -2,14 +2,10 @@ import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
+import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
-let CodeMirror;
-const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
-
-if (!SERVER_RENDERED) {
- CodeMirror = require('codemirror');
-}
+const CodeMirror = require('codemirror');
class MultiLineEditor extends Component {
constructor(props) {
@@ -78,14 +74,23 @@ class MultiLineEditor extends Component {
'Shift-Tab': false
}
});
- if (this.props.autocomplete) {
- this.editor.on('keyup', (cm, event) => {
- if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) {
- /*Enter - do not open autocomplete list just after item has been selected in it*/
- CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
- }
- });
- }
+
+
+ const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
+ const getAnywordAutocompleteHints = () => this.props.autocomplete || [];
+
+ // Setup AutoComplete Helper
+ const autoCompleteOptions = {
+ showHintsFor: ['variables'],
+ getAllVariables: getAllVariablesHandler,
+ getAnywordAutocompleteHints
+ };
+
+ this.brunoAutoCompleteCleanup = setupAutoComplete(
+ this.editor,
+ autoCompleteOptions
+ );
+
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
@@ -125,12 +130,15 @@ class MultiLineEditor extends Component {
}
componentWillUnmount() {
+ if (this.brunoAutoCompleteCleanup) {
+ this.brunoAutoCompleteCleanup();
+ }
this.editor.getWrapperElement().remove();
}
addOverlay = (variables) => {
this.variables = variables;
- defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
+ defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true);
this.editor.setOption('mode', 'brunovariables');
};
diff --git a/packages/bruno-app/src/components/Notifications/index.js b/packages/bruno-app/src/components/Notifications/index.js
index ba257bf48..0667a4146 100644
--- a/packages/bruno-app/src/components/Notifications/index.js
+++ b/packages/bruno-app/src/components/Notifications/index.js
@@ -2,7 +2,9 @@ import { IconBell } from '@tabler/icons';
import { useState } from 'react';
import StyledWrapper from './StyleWrapper';
import Modal from 'components/Modal/index';
+import Portal from 'components/Portal';
import { useEffect } from 'react';
+import { useApp } from 'providers/App';
import {
fetchNotifications,
markAllNotificationsAsRead,
@@ -11,18 +13,18 @@ import {
import { useDispatch, useSelector } from 'react-redux';
import { humanizeDate, relativeDate } from 'utils/common';
import ToolHint from 'components/ToolHint';
-import { useTheme } from 'providers/Theme';
+import DOMPurify from 'dompurify';
const PAGE_SIZE = 5;
const Notifications = () => {
const dispatch = useDispatch();
+ const { version } = useApp();
const notifications = useSelector((state) => state.notifications.notifications);
const [showNotificationsModal, setShowNotificationsModal] = useState(false);
const [selectedNotification, setSelectedNotification] = useState(null);
const [pageNumber, setPageNumber] = useState(1);
- const { storedTheme } = useTheme();
const notificationsStartIndex = (pageNumber - 1) * PAGE_SIZE;
const notificationsEndIndex = pageNumber * PAGE_SIZE;
@@ -30,7 +32,9 @@ const Notifications = () => {
const unreadNotifications = notifications.filter((notification) => !notification.read);
useEffect(() => {
- dispatch(fetchNotifications());
+ dispatch(fetchNotifications({
+ currentVersion: version
+ }));
}, []);
useEffect(() => {
@@ -66,6 +70,13 @@ const Notifications = () => {
dispatch(markNotificationAsRead({ notificationId: notification?.id }));
};
+ const getSanitizedDescription = (description) => {
+ return DOMPurify.sanitize(encodeURIComponent(description), {
+ ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
+ ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
+ });
+ };
+
const modalCustomHeader = (
) : (
- No Notifications
+ You are all caught up!
)}
+
)}
);
diff --git a/packages/bruno-app/src/components/PathDisplay/StyledWrapper.js b/packages/bruno-app/src/components/PathDisplay/StyledWrapper.js
new file mode 100644
index 000000000..326e9f8da
--- /dev/null
+++ b/packages/bruno-app/src/components/PathDisplay/StyledWrapper.js
@@ -0,0 +1,39 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ width: 100%;
+ .path-display {
+ background: ${(props) => props.theme.requestTabPanel.url.bg};
+ border-radius: 4px;
+ padding: 8px 12px;
+ font-size: 0.8125rem;
+ border: 1px solid rgba(0, 0, 0, 0.08);
+
+ .icon-column {
+ padding-right: 8px;
+ align-items: flex-start;
+ padding-top: 2px;
+ }
+
+ .path-container {
+ flex-wrap: wrap;
+ }
+
+ .path-segment {
+ white-space: nowrap;
+ }
+
+
+ .name-container, .file-extension {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+
+ .separator {
+ color: ${(props) => props.theme.text};
+ opacity: 0.6;
+ margin: 0 2px;
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/PathDisplay/index.js b/packages/bruno-app/src/components/PathDisplay/index.js
new file mode 100644
index 000000000..7413d5748
--- /dev/null
+++ b/packages/bruno-app/src/components/PathDisplay/index.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { IconFolder, IconFile } from '@tabler/icons';
+import path from 'utils/common/path';
+import StyledWrapper from './StyledWrapper';
+
+const PathDisplay = ({
+ baseName = '',
+ iconType = 'file'
+}) => {
+ return (
+
+
+
+
+ {iconType === 'file' ? : }
+
+
+ {baseName}
+
+
+
+
+ );
+};
+
+export default PathDisplay;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Preferences/Beta/StyledWrapper.js b/packages/bruno-app/src/components/Preferences/Beta/StyledWrapper.js
new file mode 100644
index 000000000..ae453450e
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Beta/StyledWrapper.js
@@ -0,0 +1,35 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .bruno-form {
+ padding: 1rem;
+ }
+
+ .submit {
+ margin-top: 1rem;
+ }
+
+ .beta-feature-item {
+ border-radius: 0.5rem;
+ border: 1px solid var(--color-gray-200);
+ background-color: var(--color-gray-50);
+ margin-bottom: 1rem;
+ }
+
+ .beta-feature-item:hover {
+ background-color: var(--color-gray-100);
+ }
+
+ .beta-feature-description {
+ margin-top: 0.25rem;
+ }
+
+ .no-features-message {
+ text-align: center;
+ padding: 2rem;
+ color: var(--color-gray-500);
+ font-style: italic;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Preferences/Beta/index.js b/packages/bruno-app/src/components/Preferences/Beta/index.js
new file mode 100644
index 000000000..dd31cbbe0
--- /dev/null
+++ b/packages/bruno-app/src/components/Preferences/Beta/index.js
@@ -0,0 +1,125 @@
+import React from 'react';
+import { useFormik } from 'formik';
+import { useSelector, useDispatch } from 'react-redux';
+import { savePreferences } from 'providers/ReduxStore/slices/app';
+import StyledWrapper from './StyledWrapper';
+import * as Yup from 'yup';
+import toast from 'react-hot-toast';
+import { IconFlask } from '@tabler/icons';
+import get from 'lodash/get';
+
+// Beta features configuration
+const BETA_FEATURES = [
+ {
+ id: 'grpc',
+ label: 'gRPC Support',
+ description: 'Enable gRPC request support for making gRPC calls to services'
+ }
+];
+
+const Beta = ({ close }) => {
+ const preferences = useSelector((state) => state.app.preferences);
+ const dispatch = useDispatch();
+
+ // Generate validation schema dynamically from beta features
+ const generateValidationSchema = () => {
+ const schemaShape = {};
+ BETA_FEATURES.forEach((feature) => {
+ schemaShape[feature.id] = Yup.boolean();
+ });
+ return Yup.object().shape(schemaShape);
+ };
+
+ // Generate initial values dynamically from beta features
+ const generateInitialValues = () => {
+ const initialValues = {};
+ BETA_FEATURES.forEach((feature) => {
+ initialValues[feature.id] = get(preferences, `beta.${feature.id}`, false);
+ });
+ return initialValues;
+ };
+
+ const betaSchema = generateValidationSchema();
+
+ const formik = useFormik({
+ initialValues: generateInitialValues(),
+ validationSchema: betaSchema,
+ onSubmit: async (values) => {
+ try {
+ const newPreferences = await betaSchema.validate(values, { abortEarly: true });
+ handleSave(newPreferences);
+ } catch (error) {
+ console.error('Beta preferences validation error:', error.message);
+ }
+ }
+ });
+
+ const handleSave = (newBetaPreferences) => {
+ dispatch(
+ savePreferences({
+ ...preferences,
+ beta: newBetaPreferences
+ })
+ )
+ .then(() => {
+ toast.success('Beta preferences saved successfully');
+ close();
+ })
+ .catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
+ };
+
+ const hasAnyBetaFeatures = Object.values(formik.values).length > 0;
+
+ return (
+
+
+
+ );
+};
+
+export default Beta;
diff --git a/packages/bruno-app/src/components/Preferences/Display/Font/index.js b/packages/bruno-app/src/components/Preferences/Display/Font/index.js
index 622ea0817..e6bbf9c3f 100644
--- a/packages/bruno-app/src/components/Preferences/Display/Font/index.js
+++ b/packages/bruno-app/src/components/Preferences/Display/Font/index.js
@@ -3,6 +3,7 @@ import get from 'lodash/get';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
+import toast from 'react-hot-toast';
const Font = ({ close }) => {
const dispatch = useDispatch();
@@ -31,7 +32,10 @@ const Font = ({ close }) => {
}
})
).then(() => {
+ toast.success('Preferences saved successfully')
close();
+ }).catch(() => {
+ toast.error('Failed to save preferences')
});
};
diff --git a/packages/bruno-app/src/components/Preferences/General/index.js b/packages/bruno-app/src/components/Preferences/General/index.js
index 2867d9841..554dd0d72 100644
--- a/packages/bruno-app/src/components/Preferences/General/index.js
+++ b/packages/bruno-app/src/components/Preferences/General/index.js
@@ -6,8 +6,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
-import path from 'path';
-import slash from 'utils/common/slash';
+import path from 'utils/common/path';
import { IconTrash } from '@tabler/icons';
const General = ({ close }) => {
@@ -81,9 +80,9 @@ const General = ({ close }) => {
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies
}
- })
- )
+ }))
.then(() => {
+ toast.success('Preferences saved successfully')
close();
})
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
@@ -126,7 +125,7 @@ const General = ({ close }) => {
className="mousetrap mr-0"
/>
- Use custom CA Certificate
+ Use Custom CA Certificate
{formik.values.customCaCertificate.filePath ? (
@@ -134,7 +133,7 @@ const General = ({ close }) => {
className={`flex items-center mt-2 pl-6 ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
>
- {path.basename(slash(formik.values.customCaCertificate.filePath))}
+ {path.basename(formik.values.customCaCertificate.filePath)}
{
className={`block ml-2 select-none ${formik.values.customCaCertificate.enabled ? '' : 'opacity-25'}`}
htmlFor="keepDefaultCaCertificatesEnabled"
>
- Keep default CA Certificates
+ Keep Default CA Certificates
diff --git a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js
index e7ac735c7..16695f60f 100644
--- a/packages/bruno-app/src/components/Preferences/ProxySettings/index.js
+++ b/packages/bruno-app/src/components/Preferences/ProxySettings/index.js
@@ -84,7 +84,10 @@ const ProxySettings = ({ close }) => {
proxy: validatedProxy
})
).then(() => {
+ toast.success('Preferences saved successfully')
close();
+ }).catch(() => {
+ toast.error('Failed to save preferences')
});
})
.catch((error) => {
diff --git a/packages/bruno-app/src/components/Preferences/index.js b/packages/bruno-app/src/components/Preferences/index.js
index 3635ca5a9..2814ed855 100644
--- a/packages/bruno-app/src/components/Preferences/index.js
+++ b/packages/bruno-app/src/components/Preferences/index.js
@@ -7,6 +7,7 @@ import General from './General';
import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
+import Beta from './Beta';
import StyledWrapper from './StyledWrapper';
@@ -37,6 +38,10 @@ const Preferences = ({ onClose }) => {
return
;
}
+ case 'beta': {
+ return
;
+ }
+
case 'support': {
return
;
}
@@ -46,7 +51,7 @@ const Preferences = ({ onClose }) => {
return (
-
+
setTab('general')}>
General
@@ -63,6 +68,9 @@ const Preferences = ({ onClose }) => {
setTab('support')}>
Support
+
setTab('beta')}>
+ Beta
+
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js
index 22a16563e..513c29500 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/ApiKeyAuth/index.js
@@ -5,21 +5,23 @@ import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
-import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
-const ApiKeyAuth = ({ item, collection }) => {
+const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
- const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {});
+ const apikeyAuth = get(request, 'auth.apikey', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const Icon = forwardRef((props, ref) => {
return (
@@ -90,7 +92,7 @@ const ApiKeyAuth = ({ item, collection }) => {
{
- dropdownTippyRef.current.hide();
+ dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'header');
}}
>
@@ -99,11 +101,11 @@ const ApiKeyAuth = ({ item, collection }) => {
{
- dropdownTippyRef.current.hide();
+ dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'queryparams');
}}
>
- Query Params
+ Query Param
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js
index a44cecc1b..aec7ee3de 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/AwsV4Auth/index.js
@@ -6,16 +6,22 @@ import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
-import { update } from 'lodash';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
+import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
-const AwsV4Auth = ({ onTokenChange, item, collection }) => {
+const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {});
+ const awsv4Auth = get(request, 'auth.awsv4', {});
+ const { isSensitive } = useDetectSensitiveField(collection);
+ const { showWarning, warningMessage } = isSensitive(awsv4Auth?.secretAccessKey);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleAccessKeyIdChange = (accessKeyId) => {
dispatch(
@@ -25,11 +31,11 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
itemUid: item.uid,
content: {
accessKeyId: accessKeyId,
- secretAccessKey: awsv4Auth.secretAccessKey,
- sessionToken: awsv4Auth.sessionToken,
- service: awsv4Auth.service,
- region: awsv4Auth.region,
- profileName: awsv4Auth.profileName
+ secretAccessKey: awsv4Auth.secretAccessKey || '',
+ sessionToken: awsv4Auth.sessionToken || '',
+ service: awsv4Auth.service || '',
+ region: awsv4Auth.region || '',
+ profileName: awsv4Auth.profileName || ''
}
})
);
@@ -42,12 +48,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- accessKeyId: awsv4Auth.accessKeyId,
- secretAccessKey: secretAccessKey,
- sessionToken: awsv4Auth.sessionToken,
- service: awsv4Auth.service,
- region: awsv4Auth.region,
- profileName: awsv4Auth.profileName
+ accessKeyId: awsv4Auth.accessKeyId || '',
+ secretAccessKey: secretAccessKey || '',
+ sessionToken: awsv4Auth.sessionToken || '',
+ service: awsv4Auth.service || '',
+ region: awsv4Auth.region || '',
+ profileName: awsv4Auth.profileName || ''
}
})
);
@@ -60,12 +66,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- accessKeyId: awsv4Auth.accessKeyId,
- secretAccessKey: awsv4Auth.secretAccessKey,
- sessionToken: sessionToken,
- service: awsv4Auth.service,
- region: awsv4Auth.region,
- profileName: awsv4Auth.profileName
+ accessKeyId: awsv4Auth.accessKeyId || '',
+ secretAccessKey: awsv4Auth.secretAccessKey || '',
+ sessionToken: sessionToken || '',
+ service: awsv4Auth.service || '',
+ region: awsv4Auth.region || '',
+ profileName: awsv4Auth.profileName || ''
}
})
);
@@ -78,12 +84,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- accessKeyId: awsv4Auth.accessKeyId,
- secretAccessKey: awsv4Auth.secretAccessKey,
- sessionToken: awsv4Auth.sessionToken,
- service: service,
- region: awsv4Auth.region,
- profileName: awsv4Auth.profileName
+ accessKeyId: awsv4Auth.accessKeyId || '',
+ secretAccessKey: awsv4Auth.secretAccessKey || '',
+ sessionToken: awsv4Auth.sessionToken || '',
+ service: service || '',
+ region: awsv4Auth.region || '',
+ profileName: awsv4Auth.profileName || ''
}
})
);
@@ -96,12 +102,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- accessKeyId: awsv4Auth.accessKeyId,
- secretAccessKey: awsv4Auth.secretAccessKey,
- sessionToken: awsv4Auth.sessionToken,
- service: awsv4Auth.service,
- region: region,
- profileName: awsv4Auth.profileName
+ accessKeyId: awsv4Auth.accessKeyId || '',
+ secretAccessKey: awsv4Auth.secretAccessKey || '',
+ sessionToken: awsv4Auth.sessionToken || '',
+ service: awsv4Auth.service || '',
+ region: region || '',
+ profileName: awsv4Auth.profileName || ''
}
})
);
@@ -114,12 +120,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- accessKeyId: awsv4Auth.accessKeyId,
- secretAccessKey: awsv4Auth.secretAccessKey,
- sessionToken: awsv4Auth.sessionToken,
- service: awsv4Auth.service,
- region: awsv4Auth.region,
- profileName: profileName
+ accessKeyId: awsv4Auth.accessKeyId || '',
+ secretAccessKey: awsv4Auth.secretAccessKey || '',
+ sessionToken: awsv4Auth.sessionToken || '',
+ service: awsv4Auth.service || '',
+ region: awsv4Auth.region || '',
+ profileName: profileName || ''
}
})
);
@@ -141,7 +147,7 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
Secret Access Key
-
+
{
item={item}
isSecret={true}
/>
+
+ {showWarning && }
Session Token
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js
index 8582a53cd..355b2bfd1 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/BasicAuth/index.js
@@ -1,4 +1,6 @@
import React from 'react';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
+import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -7,14 +9,19 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
-const BasicAuth = ({ item, collection }) => {
+const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
+ const basicAuth = get(request, 'auth.basic', {});
+ const { isSensitive } = useDetectSensitiveField(collection);
+ const { showWarning, warningMessage } = isSensitive(basicAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleUsernameChange = (username) => {
dispatch(
@@ -23,8 +30,8 @@ const BasicAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- username: username,
- password: basicAuth.password
+ username: username || '',
+ password: basicAuth.password || ''
}
})
);
@@ -37,8 +44,8 @@ const BasicAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- username: basicAuth.username,
- password: password
+ username: basicAuth.username || '',
+ password: password || ''
}
})
);
@@ -60,7 +67,7 @@ const BasicAuth = ({ item, collection }) => {
Password
-
+
{
item={item}
isSecret={true}
/>
+ {showWarning && }
);
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js
index bef4d062a..12d65cdbc 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/BearerAuth/index.js
@@ -1,4 +1,6 @@
import React from 'react';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
+import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -7,16 +9,20 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
-const BearerAuth = ({ item, collection }) => {
+const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const bearerToken = item.draft
- ? get(item, 'draft.request.auth.bearer.token', '')
- : get(item, 'request.auth.bearer.token', '');
+ // Use the request prop directly like OAuth2ClientCredentials does
+ const bearerToken = get(request, 'auth.bearer.token', '');
+ const { isSensitive } = useDetectSensitiveField(collection);
+ const { showWarning, warningMessage } = isSensitive(bearerToken);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleTokenChange = (token) => {
dispatch(
@@ -34,7 +40,7 @@ const BearerAuth = ({ item, collection }) => {
return (
Token
-
+
{
item={item}
isSecret={true}
/>
+ {showWarning && }
);
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js
index e91ed8d1f..e872894d8 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/DigestAuth/index.js
@@ -1,20 +1,26 @@
import React from 'react';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
+import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
-const DigestAuth = ({ item, collection }) => {
+const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
+ const digestAuth = get(request, 'auth.digest', {});
+ const { isSensitive } = useDetectSensitiveField(collection);
+ const { showWarning, warningMessage } = isSensitive(digestAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleUsernameChange = (username) => {
dispatch(
@@ -23,8 +29,8 @@ const DigestAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- username: username,
- password: digestAuth.password
+ username: username || '',
+ password: digestAuth.password || ''
}
})
);
@@ -37,8 +43,8 @@ const DigestAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- username: digestAuth.username,
- password: password
+ username: digestAuth.username || '',
+ password: password || ''
}
})
);
@@ -60,7 +66,7 @@ const DigestAuth = ({ item, collection }) => {
Password
-
+
{
item={item}
isSecret={true}
/>
+ {showWarning && }
);
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js
index 65e756041..b1cdf474c 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/NTLMAuth/index.js
@@ -1,4 +1,6 @@
import React from 'react';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
+import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -7,14 +9,19 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
-const NTLMAuth = ({ item, collection }) => {
+const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const ntlmAuth = item.draft ? get(item, 'draft.request.auth.ntlm', {}) : get(item, 'request.auth.ntlm', {});
+ const ntlmAuth = get(request, 'auth.ntlm', {});
+ const { isSensitive } = useDetectSensitiveField(collection);
+ const { showWarning, warningMessage } = isSensitive(ntlmAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleUsernameChange = (username) => {
dispatch(
@@ -23,10 +30,9 @@ const NTLMAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- username: username,
- password: ntlmAuth.password,
- domain: ntlmAuth.domain
-
+ username: username || '',
+ password: ntlmAuth.password || '',
+ domain: ntlmAuth.domain || ''
}
})
);
@@ -39,9 +45,9 @@ const NTLMAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- username: ntlmAuth.username,
- password: password,
- domain: ntlmAuth.domain
+ username: ntlmAuth.username || '',
+ password: password || '',
+ domain: ntlmAuth.domain || ''
}
})
);
@@ -54,9 +60,9 @@ const NTLMAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- username: ntlmAuth.username,
- password: ntlmAuth.password,
- domain: domain
+ username: ntlmAuth.username || '',
+ password: ntlmAuth.password || '',
+ domain: domain || ''
}
})
);
@@ -78,7 +84,7 @@ const NTLMAuth = ({ item, collection }) => {
Password
-
+
{
item={item}
isSecret={true}
/>
+ {showWarning && }
Domain
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/StyledWrapper.js
new file mode 100644
index 000000000..712367fd7
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/StyledWrapper.js
@@ -0,0 +1,66 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .tabs {
+ .tab {
+ cursor: pointer;
+ padding: 4px 8px !important;
+ font-size: 12px;
+ border-radius: 4px;
+
+ &:hover {
+ background-color: ${(props) => props.theme.mode === 'dark' ? 'rgba(99, 102, 241, 0.1)' : 'rgba(99, 102, 241, 0.1)'};
+ }
+
+ &.active {
+ background-color: ${(props) => props.theme.mode === 'dark' ? 'rgba(99, 102, 241, 0.2)' : 'rgba(99, 102, 241, 0.1)'};
+ color: ${(props) => props.theme.mode === 'dark' ? '#6366f1' : '#4f46e5'};
+ font-weight: 500;
+ }
+ }
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ td {
+ padding: 6px 10px;
+ }
+ }
+
+ .additional-parameter-sends-in-selector {
+ select {
+ height: 32px;
+ width: 100%;
+ border: 1px solid ${(props) => props.theme.input.border};
+ border-radius: 4px;
+ padding: 0 8px;
+
+ &:focus {
+ outline: none;
+ border-color: ${(props) => props.theme.mode === 'dark' ? '#6366f1' : '#4f46e5'};
+ }
+ }
+ }
+
+ .add-additional-param-actions {
+ &:hover {
+ color: ${(props) => props.theme.mode === 'dark' ? '#6366f1' : '#4f46e5'};
+ }
+ }
+`
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/index.js
new file mode 100644
index 000000000..1d2f81bee
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AdditionalParams/index.js
@@ -0,0 +1,306 @@
+import { useDispatch } from "react-redux";
+import React, { useState } from 'react';
+import get from 'lodash/get';
+import { useTheme } from 'providers/Theme';
+import { IconPlus, IconTrash, IconAdjustmentsHorizontal } from '@tabler/icons';
+import { cloneDeep } from "lodash";
+import SingleLineEditor from "components/SingleLineEditor/index";
+import StyledWrapper from "./StyledWrapper";
+import Table from "components/Table/index";
+
+const AdditionalParams = ({ item = {}, request, updateAuth, collection, handleSave }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+
+ const oAuth = get(request, 'auth.oauth2', {});
+ const {
+ grantType,
+ additionalParameters = {}
+ } = oAuth;
+
+ const [activeTab, setActiveTab] = useState(
+ (grantType == 'authorization_code' || grantType == 'implicit') ? 'authorization' : 'token'
+ );
+
+ const isEmptyParam = (param) => {
+ return !param.name.trim() && !param.value.trim();
+ };
+
+ const hasEmptyRow = () => {
+ const tabParams = additionalParameters[activeTab] || [];
+ return tabParams.some(isEmptyParam);
+ };
+
+ const updateAdditionalParameters = ({ updatedAdditionalParameters }) => {
+ const filteredParams = cloneDeep(updatedAdditionalParameters);
+
+ Object.keys(filteredParams).forEach(paramType => {
+ if (filteredParams[paramType]?.length) {
+ filteredParams[paramType] = filteredParams[paramType].filter(param =>
+ param.name.trim() || param.value.trim()
+ );
+
+ if (filteredParams[paramType].length === 0) {
+ delete filteredParams[paramType];
+ }
+ } else if (Array.isArray(filteredParams[paramType]) && filteredParams[paramType].length === 0) {
+ // Remove empty arrays
+ delete filteredParams[paramType];
+ }
+ });
+
+ dispatch(
+ updateAuth({
+ mode: 'oauth2',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ ...oAuth,
+ additionalParameters: Object.keys(filteredParams).length > 0 ? filteredParams : undefined
+ }
+ })
+ );
+ }
+
+ const handleUpdateAdditionalParam = ({ paramType, key, paramIndex, value }) => {
+ const updatedAdditionalParameters = cloneDeep(additionalParameters);
+
+ if (!updatedAdditionalParameters[paramType]) {
+ updatedAdditionalParameters[paramType] = [];
+ }
+
+ if (!updatedAdditionalParameters[paramType][paramIndex]) {
+ updatedAdditionalParameters[paramType][paramIndex] = {
+ name: '',
+ value: '',
+ sendIn: 'headers',
+ enabled: true
+ };
+ }
+
+ updatedAdditionalParameters[paramType][paramIndex][key] = value;
+
+ // Only filter when updating a parameter
+ updateAdditionalParameters({ updatedAdditionalParameters });
+ }
+
+ const handleDeleteAdditionalParam = ({ paramType, paramIndex }) => {
+ const updatedAdditionalParameters = cloneDeep(additionalParameters);
+
+ if (updatedAdditionalParameters[paramType]?.length) {
+ updatedAdditionalParameters[paramType] = updatedAdditionalParameters[paramType].filter((_, index) => index !== paramIndex);
+
+ // If the array is now empty, ensure we're not sending empty arrays
+ if (updatedAdditionalParameters[paramType].length === 0) {
+ delete updatedAdditionalParameters[paramType];
+ }
+ }
+
+ updateAdditionalParameters({ updatedAdditionalParameters });
+ }
+
+ const handleAddNewAdditionalParam = () => {
+ // Prevent adding multiple empty rows
+ if (hasEmptyRow()) {
+ return;
+ }
+
+ const paramType = activeTab;
+ const localAdditionalParameters = cloneDeep(additionalParameters);
+
+ if (!localAdditionalParameters[paramType]) {
+ localAdditionalParameters[paramType] = [];
+ }
+
+ localAdditionalParameters[paramType] = [
+ ...localAdditionalParameters[paramType],
+ {
+ name: '',
+ value: '',
+ sendIn: 'headers',
+ enabled: true
+ }
+ ];
+
+ // Don't filter here to allow the empty row to display in UI
+ // But don't permanently store it in state until it has values
+ dispatch(
+ updateAuth({
+ mode: 'oauth2',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ ...oAuth,
+ additionalParameters: localAdditionalParameters,
+ }
+ })
+ );
+ }
+
+ // Add a class to the Add Parameter button if it's disabled
+ const addButtonDisabled = hasEmptyRow();
+
+ // Define available tabs for each grant type
+ const getAvailableTabs = (grantType) => {
+ const tabConfig = {
+ 'authorization_code': ['authorization', 'token', 'refresh'],
+ 'implicit': ['authorization'],
+ 'password': ['token', 'refresh'],
+ 'client_credentials': ['token', 'refresh']
+ };
+ return tabConfig[grantType] || ['token', 'refresh'];
+ };
+
+ const availableTabs = getAvailableTabs(grantType);
+
+ const renderTab = (tabKey, tabLabel) => (
+
setActiveTab(tabKey)}
+ >
+ {tabLabel}
+
+ );
+
+ return (
+
+
+
+
+
+
+ Additional Parameters
+
+
+
+
+ {availableTabs.includes('authorization') && renderTab('authorization', 'Authorization')}
+ {availableTabs.includes('token') && renderTab('token', 'Token')}
+ {availableTabs.includes('refresh') && renderTab('refresh', 'Refresh')}
+
+
+
+ {(additionalParameters?.[activeTab] || []).map((param, index) =>
+
+
+ handleUpdateAdditionalParam({
+ paramType: activeTab,
+ key: 'name',
+ paramIndex: index,
+ value
+ })}
+ collection={collection}
+ onSave={handleSave}
+ />
+
+
+ handleUpdateAdditionalParam({
+ paramType: activeTab,
+ key: 'value',
+ paramIndex: index,
+ value
+ })}
+ collection={collection}
+ onSave={handleSave}
+ />
+
+
+
+ {
+ handleUpdateAdditionalParam({
+ paramType: activeTab,
+ key: 'sendIn',
+ paramIndex: index,
+ value: e.target.value
+ })
+ }}
+ className="mousetrap bg-transparent"
+ >
+ {sendInOptionsMap[grantType || 'authorization_code'][activeTab].map((optionValue) => (
+
+ {optionValue}
+
+ ))}
+
+
+
+
+
+ {
+ handleUpdateAdditionalParam({
+ paramType: activeTab,
+ key: 'enabled',
+ paramIndex: index,
+ value: e.target.checked
+ })
+ }}
+ />
+ {
+ handleDeleteAdditionalParam({
+ paramType: activeTab,
+ paramIndex: index
+ })
+ }}
+ >
+
+
+
+
+
+ )}
+
+
+
+
+ Add Parameter
+
+
+ )
+}
+
+export default AdditionalParams;
+
+const sendInOptionsMap = {
+ 'authorization_code': {
+ 'authorization': ['headers', 'queryparams'],
+ 'token': ['headers', 'queryparams', 'body'],
+ 'refresh': ['headers', 'queryparams', 'body']
+ },
+ 'password': {
+ 'token': ['headers', 'queryparams', 'body'],
+ 'refresh': ['headers', 'queryparams', 'body']
+ },
+ 'client_credentials': {
+ 'token': ['headers', 'queryparams', 'body'],
+ 'refresh': ['headers', 'queryparams', 'body']
+ },
+ 'implicit': {
+ 'authorization': ['headers', 'queryparams']
+ }
+}
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/StyledWrapper.js
index 856f35b9b..b06deaedf 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/StyledWrapper.js
@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
+
+ .token-placement-selector {
+ padding: 0.5rem 0px;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+ min-width: 100px;
+
+ .dropdown {
+ width: fit-content;
+ min-width: 100px;
+
+ div[data-tippy-root] {
+ width: fit-content;
+ min-width: 100px;
+ }
+ .tippy-box {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+
+ .tippy-content: {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+ }
+ }
+ }
+
+ .token-placement-label {
+ width: fit-content;
+ // color: ${(props) => props.theme.colors.text.yellow};
+ justify-content: space-between;
+ padding: 0 0.5rem;
+ min-width: 100px;
+ }
+
+ .dropdown-item {
+ padding: 0.2rem 0.6rem !important;
+ }
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js
index 2bb5dcc35..7c890513e 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/AuthorizationCode/index.js
@@ -1,28 +1,67 @@
-import React from 'react';
+import React, { useRef, forwardRef } from 'react';
+import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
+import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
+import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
-import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
-import { clearOauth2Cache } from 'utils/network/index';
-import toast from 'react-hot-toast';
+import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
+import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
+import AdditionalParams from '../AdditionalParams/index';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
-const OAuth2AuthorizationCode = ({ item, collection }) => {
+const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+ const { isSensitive } = useDetectSensitiveField(collection);
+ const oAuth = get(request, 'auth.oauth2', {});
+ const {
+ callbackUrl,
+ authorizationUrl,
+ accessTokenUrl,
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement,
+ state,
+ pkce,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken,
+ additionalParameters
+ } = oAuth;
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
+ const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
+ const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
- const handleRun = async () => {
- dispatch(sendRequest(item, collection.uid));
- };
+ const TokenPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {tokenPlacement == 'url' ? 'URL' : 'Headers'}
+
+
+ );
+ });
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+ const CredentialsPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
+
+
+ );
+ });
- const { callbackUrl, authorizationUrl, accessTokenUrl, clientId, clientSecret, scope, state, pkce } = oAuth;
+ const handleSave = () => { save(); };
const handleChange = (key, value) => {
dispatch(
@@ -40,7 +79,16 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
state,
scope,
pkce,
- [key]: value
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken,
+ additionalParameters,
+ [key]: value,
}
})
);
@@ -61,32 +109,41 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
clientSecret,
state,
scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ autoFetchToken,
+ additionalParameters,
pkce: !Boolean(oAuth?.['pkce'])
}
})
);
};
- const handleClearCache = (e) => {
- clearOauth2Cache(collection?.uid)
- .then(() => {
- toast.success('cleared cache successfully');
- })
- .catch((err) => {
- toast.error(err.message);
- });
- };
-
return (
+
+
+
+
+
+
+ Configuration
+
+
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
+ const value = oAuth[key] || '';
+ const { showWarning, warningMessage } = isSensitive(value);
+
return (
-
-
{label}
-
+
+
{label}
+
handleChange(key, val)}
@@ -95,12 +152,38 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
item={item}
isSecret={isSecret}
/>
+ {isSecret && showWarning && }
);
})}
+
+
Add Credentials to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'body');
+ }}
+ >
+ Request Body
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'basic_auth_header');
+ }}
+ >
+ Basic Auth Header
+
+
+
+
- Use PKCE
+ Use PKCE
{
onChange={handlePKCEToggle}
/>
-
-
- Get Access Token
-
-
- Clear Cache
-
+
+
+
Token ID
+
+ handleChange('credentialsId', val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
Add token to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'header');
+ }}
+ >
+ Header
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'url');
+ }}
+ >
+ URL
+
+
+
+
+ {
+ tokenPlacement === 'header' ?
+
+
Header Prefix
+
+ handleChange('tokenHeaderPrefix', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ :
+
+
Query Param Key
+
+ handleChange('tokenQueryKey', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ }
+
+
+
+
+
+ Advanced Settings
+
+
+
+
+
Refresh Token URL
+
+ handleChange("refreshTokenUrl", val)}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
+
+ {/* Automatically Fetch Token */}
+
+
handleChange('autoFetchToken', e.target.checked)}
+ className="cursor-pointer ml-1"
+ />
+
Automatically fetch token if not found
+
+
+
+
+ Automatically fetch a new token when you try to access a resource and don't have one.
+
+
+
+
+
+ {/* Auto Refresh Token (With Refresh URL) */}
+
+
handleChange('autoRefreshToken', e.target.checked)}
+ className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isAutoRefreshDisabled}
+ />
+
Auto refresh token (with refresh URL)
+
+
+
+
+ Automatically refresh your token using the refresh URL when it expires.
+
+
+
+
+
+
);
};
-export default OAuth2AuthorizationCode;
+export default OAuth2AuthorizationCode;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/StyledWrapper.js
index 856f35b9b..b06deaedf 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/StyledWrapper.js
@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
+
+ .token-placement-selector {
+ padding: 0.5rem 0px;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+ min-width: 100px;
+
+ .dropdown {
+ width: fit-content;
+ min-width: 100px;
+
+ div[data-tippy-root] {
+ width: fit-content;
+ min-width: 100px;
+ }
+ .tippy-box {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+
+ .tippy-content: {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+ }
+ }
+ }
+
+ .token-placement-label {
+ width: fit-content;
+ // color: ${(props) => props.theme.colors.text.yellow};
+ justify-content: space-between;
+ padding: 0 0.5rem;
+ min-width: 100px;
+ }
+
+ .dropdown-item {
+ padding: 0.2rem 0.6rem !important;
+ }
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js
index a43c8f0ad..253adeac0 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/ClientCredentials/index.js
@@ -1,26 +1,65 @@
-import React from 'react';
+import React, { useRef, forwardRef } from 'react';
+import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
+import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHelp } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
-import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
+import Dropdown from 'components/Dropdown';
+import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
+import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
+import AdditionalParams from '../AdditionalParams/index';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
-const OAuth2ClientCredentials = ({ item, collection }) => {
+const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+ const { isSensitive } = useDetectSensitiveField(collection);
+ const oAuth = get(request, 'auth.oauth2', {});
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
+ const {
+ accessTokenUrl,
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken,
+ additionalParameters
+ } = oAuth;
- const handleRun = async () => {
- dispatch(sendRequest(item, collection.uid));
- };
+ const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
+ const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
- const { accessTokenUrl, clientId, clientSecret, scope } = oAuth;
+ const handleSave = () => { save(); };
+
+ const TokenPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {tokenPlacement == 'url' ? 'URL' : 'Headers'}
+
+
+ );
+ });
+
+ const CredentialsPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
+
+
+ );
+ });
const handleChange = (key, value) => {
dispatch(
@@ -34,6 +73,15 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
clientId,
clientSecret,
scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken,
+ additionalParameters,
[key]: value
}
})
@@ -42,14 +90,26 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
return (
+
+
+
+
+
+
+ Configuration
+
+
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
+ const value = oAuth[key] || '';
+ const { showWarning, warningMessage } = isSensitive(value);
+
return (
-
-
{label}
-
+
+
{label}
+
handleChange(key, val)}
@@ -58,13 +118,190 @@ const OAuth2ClientCredentials = ({ item, collection }) => {
item={item}
isSecret={isSecret}
/>
+ {isSecret && showWarning && }
);
})}
-
- Get Access Token
-
+
+
Add Credentials to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'body');
+ }}
+ >
+ Request Body
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'basic_auth_header');
+ }}
+ >
+ Basic Auth Header
+
+
+
+
+
+
+
Token ID
+
+ handleChange('credentialsId', val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
Add token to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'header');
+ }}
+ >
+ Header
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'url');
+ }}
+ >
+ URL
+
+
+
+
+ {
+ tokenPlacement === 'header' ?
+
+
Header Prefix
+
+ handleChange('tokenHeaderPrefix', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ :
+
+
Query Param Key
+
+ handleChange('tokenQueryKey', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ }
+
+
+
+
+
+ Advanced Settings
+
+
+
+
+
Refresh Token URL
+
+ handleChange("refreshTokenUrl", val)}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
+
+ {/* Automatically Fetch Token */}
+
+
handleChange('autoFetchToken', e.target.checked)}
+ className="cursor-pointer ml-1"
+ />
+
Automatically fetch token if not found
+
+
+
+
+ Automatically fetch a new token when you try to access a resource and don't have one.
+
+
+
+
+
+ {/* Auto Refresh Token (With Refresh URL) */}
+
+
handleChange('autoRefreshToken', e.target.checked)}
+ className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isAutoRefreshDisabled}
+ />
+
Auto refresh token (with refresh URL)
+
+
+
+
+ Automatically refresh your token using the refresh URL when it expires.
+
+
+
+
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js
index 3fa12b947..f89aa9579 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/GrantTypeSelector/index.js
@@ -3,18 +3,20 @@ import get from 'lodash/get';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
-import { IconCaretDown } from '@tabler/icons';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
+import { IconCaretDown, IconKey } from '@tabler/icons';
import { humanizeGrantType } from 'utils/collections';
import { useEffect } from 'react';
+import { useState } from 'react';
-const GrantTypeSelector = ({ item, collection }) => {
+const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
+ const oAuth = get(request, 'auth.oauth2', {});
+ const [valuesCache, setValuesCache] = useState({
+ ...oAuth
+ });
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
-
const Icon = forwardRef((props, ref) => {
return (
@@ -24,13 +26,19 @@ const GrantTypeSelector = ({ item, collection }) => {
});
const onGrantTypeChange = (grantType) => {
+ let updatedValues = {
+ ...valuesCache,
+ ...oAuth,
+ grantType
+ };
+ setValuesCache(updatedValues);
dispatch(
updateAuth({
mode: 'oauth2',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- grantType
+ ...updatedValues
}
})
);
@@ -46,7 +54,18 @@ const GrantTypeSelector = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- grantType: 'authorization_code'
+ grantType: 'authorization_code',
+ accessTokenUrl: '',
+ username: '',
+ password: '',
+ clientId: '',
+ clientSecret: '',
+ scope: '',
+ credentialsPlacement: 'body',
+ credentialsId: 'credentials',
+ tokenPlacement: 'header',
+ tokenHeaderPrefix: 'Bearer',
+ tokenQueryKey: 'access_token',
}
})
);
@@ -54,7 +73,14 @@ const GrantTypeSelector = ({ item, collection }) => {
return (
- Grant Type
+
} placement="bottom-end">
{
>
Authorization Code
+
{
+ dropdownTippyRef.current.hide();
+ onGrantTypeChange('implicit');
+ }}
+ >
+ Implicit
+
{
@@ -89,4 +124,4 @@ const GrantTypeSelector = ({ item, collection }) => {
);
};
-export default GrantTypeSelector;
+export default GrantTypeSelector;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/GrantTypeSelector/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/StyledWrapper.js
similarity index 55%
rename from packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/GrantTypeSelector/StyledWrapper.js
rename to packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/StyledWrapper.js
index bb42bdb49..273806001 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/GrantTypeSelector/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/StyledWrapper.js
@@ -1,54 +1,61 @@
import styled from 'styled-components';
const Wrapper = styled.div`
- font-size: 0.8125rem;
+ label {
+ font-size: 0.8125rem;
+ }
+ .oauth2-input-wrapper {
+ max-width: 400px;
+ padding: 0.15rem 0.4rem;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+ }
- .grant-type-mode-selector {
+ .token-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
+ min-width: 100px;
.dropdown {
width: fit-content;
+ min-width: 100px;
div[data-tippy-root] {
width: fit-content;
+ min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
+ min-width: 100px;
- .tippy-content: {
+ .tippy-content {
width: fit-content;
max-width: none !important;
+ min-width: 100px;
}
}
}
- .grant-type-label {
+ .token-placement-label {
width: fit-content;
- color: ${(props) => props.theme.colors.text.yellow};
justify-content: space-between;
padding: 0 0.5rem;
+ min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
-
- .label-item {
- padding: 0.2rem 0.6rem !important;
- }
}
-
- .caret {
- color: rgb(140, 140, 140);
- fill: rgb(140 140 140);
- }
- label {
- font-size: 0.8125rem;
+
+ .checkbox-label {
+ color: ${(props) => props.theme.colors.text.primary};
+ user-select: none;
}
`;
-export default Wrapper;
+export default Wrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js
new file mode 100644
index 000000000..9715aa3ce
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/index.js
@@ -0,0 +1,233 @@
+import React, { useRef, forwardRef, useMemo } from 'react';
+import get from 'lodash/get';
+import { useTheme } from 'providers/Theme';
+import { useDispatch } from 'react-redux';
+import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
+import Dropdown from 'components/Dropdown';
+import SingleLineEditor from 'components/SingleLineEditor';
+import Wrapper from './StyledWrapper';
+import { inputsConfig } from './inputsConfig';
+import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
+import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
+import AdditionalParams from '../AdditionalParams/index';
+import { getAllVariables } from 'utils/collections/index';
+import { interpolate } from '@usebruno/common';
+
+const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
+ const oAuth = get(request, 'auth.oauth2', {});
+ const {
+ callbackUrl,
+ authorizationUrl,
+ clientId,
+ scope,
+ state,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ autoFetchToken
+ } = oAuth;
+
+ const interpolatedAuthUrl = useMemo(() => {
+ const variables = getAllVariables(collection, item);
+ return interpolate(authorizationUrl, variables);
+ }, [collection, item, authorizationUrl]);
+
+ const TokenPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {tokenPlacement == 'url' ? 'URL' : 'Headers'}
+
+
+ );
+ });
+
+ const handleSave = () => { save(); };
+
+ const handleChange = (key, value) => {
+ dispatch(
+ updateAuth({
+ mode: 'oauth2',
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ content: {
+ grantType: 'implicit',
+ callbackUrl,
+ authorizationUrl,
+ clientId,
+ state,
+ scope,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ autoFetchToken,
+ [key]: value,
+ }
+ })
+ );
+ };
+
+ const handleAutoFetchTokenToggle = (e) => {
+ handleChange('autoFetchToken', e.target.checked);
+ };
+
+ return (
+
+
+
+
+
+
+
+ Configuration
+
+
+ {inputsConfig.map((input) => {
+ const { key, label, isSecret } = input;
+ return (
+
+
{label}
+
+ handleChange(key, val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ isSecret={isSecret}
+ />
+
+
+ );
+ })}
+
+
+
+
+
Token ID
+
+ handleChange('credentialsId', val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
+
Add Token to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'header');
+ }}
+ >
+ Headers
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'url');
+ }}
+ >
+ URL
+
+
+
+
+
+ {tokenPlacement == 'header' ? (
+
+
Header Prefix
+
+ handleChange('tokenHeaderPrefix', val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+ ) : (
+
+
URL Query Key
+
+ handleChange('tokenQueryKey', val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+ )}
+
+
+
+
+
+
+ Advanced Options
+
+
+
+
+
+
Auto fetch token
+
+
+
+
+ Automatically fetch a new token when the current one expires.
+
+
+
+
+
+
+
+
+ );
+};
+
+export default OAuth2Implicit;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/inputsConfig.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/inputsConfig.js
similarity index 63%
rename from packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/inputsConfig.js
rename to packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/inputsConfig.js
index a100ce8e5..86040b838 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Auth/OAuth2/AuthorizationCode/inputsConfig.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Implicit/inputsConfig.js
@@ -7,19 +7,10 @@ const inputsConfig = [
key: 'authorizationUrl',
label: 'Authorization URL'
},
- {
- key: 'accessTokenUrl',
- label: 'Access Token URL'
- },
{
key: 'clientId',
label: 'Client ID'
},
- {
- key: 'clientSecret',
- label: 'Client Secret',
- isSecret: true
- },
{
key: 'scope',
label: 'Scope'
@@ -30,4 +21,4 @@ const inputsConfig = [
}
];
-export { inputsConfig };
+export { inputsConfig };
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js
new file mode 100644
index 000000000..3d3dc697d
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2ActionButtons/index.js
@@ -0,0 +1,125 @@
+import { useMemo, useState } from "react";
+import { useDispatch } from "react-redux";
+import toast from 'react-hot-toast';
+import { cloneDeep, find } from 'lodash';
+import { IconLoader2 } from '@tabler/icons';
+import { interpolate } from '@usebruno/common';
+import { fetchOauth2Credentials, clearOauth2Cache, refreshOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
+import { getAllVariables } from "utils/collections/index";
+
+const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, credentialsId }) => {
+ const { uid: collectionUid } = collection;
+
+ const dispatch = useDispatch();
+ const [fetchingToken, toggleFetchingToken] = useState(false);
+ const [refreshingToken, toggleRefreshingToken] = useState(false);
+
+ const interpolatedAccessTokenUrl = useMemo(() => {
+ const variables = getAllVariables(collection, item);
+ return interpolate(accessTokenUrl, variables);
+ }, [collection, item, accessTokenUrl]);
+
+ const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedAccessTokenUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId);
+ const creds = credentialsData?.credentials || {};
+
+ const handleFetchOauth2Credentials = async () => {
+ let requestCopy = cloneDeep(request);
+ requestCopy.oauth2 = requestCopy?.auth.oauth2;
+ requestCopy.headers = {};
+ toggleFetchingToken(true);
+ try {
+ const result = await dispatch(fetchOauth2Credentials({
+ itemUid: item.uid,
+ request: requestCopy,
+ collection,
+ forceGetToken: true
+ }));
+
+ toggleFetchingToken(false);
+
+ // Check if the result contains error or if access_token is missing
+ if (!result || !result.access_token) {
+ const errorMessage = result?.error || 'No access token received from authorization server';
+ console.error(errorMessage);
+ toast.error(errorMessage);
+ return;
+ }
+
+ toast.success('Token fetched successfully!');
+ }
+ catch (error) {
+ console.error('could not fetch the token!');
+ console.error(error);
+ toggleFetchingToken(false);
+ toast.error(error?.message || 'An error occurred while fetching token!');
+ }
+ }
+
+ const handleRefreshAccessToken = async () => {
+ let requestCopy = cloneDeep(request);
+ requestCopy.oauth2 = requestCopy?.auth.oauth2;
+ requestCopy.headers = {};
+ toggleRefreshingToken(true);
+ try {
+ const result = await dispatch(refreshOauth2Credentials({
+ itemUid: item.uid,
+ request: requestCopy,
+ collection,
+ forceGetToken: true
+ }));
+
+ toggleRefreshingToken(false);
+
+ // Check if the result contains error or if access_token is missing
+ if (!result || !result.access_token) {
+ const errorMessage = result?.error || 'No access token received from authorization server';
+ console.error(errorMessage);
+ toast.error(errorMessage);
+ return;
+ }
+
+ toast.success('Token refreshed successfully!');
+ }
+ catch(error) {
+ console.error(error);
+ toggleRefreshingToken(false);
+ toast.error(error?.message || 'An error occurred while refreshing token!');
+ }
+ };
+
+ const handleClearCache = (e) => {
+ dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAccessTokenUrl, credentialsId }))
+ .then(() => {
+ toast.success('Cleared cache successfully');
+ })
+ .catch((err) => {
+ toast.error(err.message);
+ });
+ };
+
+ return (
+
+
+ Get Access Token{fetchingToken? : ""}
+
+ {creds?.refresh_token ?
+
+ Refresh Token{refreshingToken? : ""}
+
+ : null}
+
+ Clear Cache
+
+
+ )
+}
+
+export default Oauth2ActionButtons;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/StyledWrapper.js
new file mode 100644
index 000000000..80d13c0e5
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ ol[role="tree"] {
+ overflow: hidden;
+ }
+ ol[role="group"] span {
+ line-break: anywhere;
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js
new file mode 100644
index 000000000..7692b5891
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/Oauth2TokenViewer/index.js
@@ -0,0 +1,172 @@
+import { useState, useEffect, useMemo } from "react";
+import { find } from "lodash";
+import StyledWrapper from "./StyledWrapper";
+import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
+import { getAllVariables } from 'utils/collections/index';
+import { interpolate } from '@usebruno/common';
+
+const TokenSection = ({ title, token }) => {
+ if (!token) return null;
+
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [decodedToken, setDecodedToken] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ if (token) {
+ try {
+ const parts = token.split('.');
+ if (parts.length === 3) {
+ const payload = JSON.parse(atob(parts[1]));
+ setDecodedToken(payload);
+ }
+ } catch (err) {
+ console.error('Error decoding token:', err);
+ }
+ }
+ }, [token]);
+
+ const handleCopy = async (text) => {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+
+
setIsExpanded(!isExpanded)}
+ >
+
+ {isExpanded ?
+
:
+
+ }
+
+
{title}
+ {decodedToken?.exp && }
+
+
+
+ {isExpanded && (
+
+
+
+ handleCopy(token)}
+ className="p-1 bg-indigo-100 dark:hover:bg-indigo-200 rounded"
+ title="Copy token"
+ >
+ {copied ?
+ :
+
+ }
+
+
+
+ {token}
+
+
+ {decodedToken && (
+
+
Decoded Payload
+
+ {Object.entries(decodedToken).map(([key, value]) => (
+
+ {key}:
+
+ {typeof value === 'object' ? JSON.stringify(value) : value.toString()}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ );
+};
+
+const formatExpiryTime = (seconds) => {
+ if (seconds < 60) return `${seconds}s`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
+};
+
+const ExpiryTimer = ({ expiresIn }) => {
+ if (!expiresIn) return null;
+
+ const calculateTimeLeft = () => Math.max(0, Math.floor(expiresIn - Date.now() / 1000));
+
+ const [timeLeft, setTimeLeft] = useState(calculateTimeLeft);
+
+ useEffect(() => {
+ setTimeLeft(calculateTimeLeft());
+
+ const timer = setInterval(() => {
+ setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
+ }, 1000);
+
+ return () => clearInterval(timer);
+ }, [expiresIn]);
+
+ return (
+
+ {timeLeft > 0 ? `Expires in ${formatExpiryTime(timeLeft)}` : `Expired`}
+
+ );
+};
+
+
+const Oauth2TokenViewer = ({ collection, item, url, credentialsId, handleRun }) => {
+ const { uid: collectionUid } = collection;
+
+ const interpolatedUrl = useMemo(() => {
+ const variables = getAllVariables(collection, item);
+ return interpolate(url, variables);
+ }, [collection, item, url]);
+
+ const credentialsData = find(collection?.oauth2Credentials, creds => creds?.url == interpolatedUrl && creds?.collectionUid == collectionUid && creds?.credentialsId == credentialsId);
+ const creds = credentialsData?.credentials || {};
+
+ return (
+
+ {Object.keys(creds)?.length ? (
+ creds?.error ? (
+ Error fetching token. Check network logs for more details.
+ ) : (
+
+
+
+
+ {(creds.token_type || creds.scope) ?
+
+ {creds.token_type ?
+ Token Type:
+ {creds.token_type}
+
: null}
+ {creds?.scope ?
+ Scope:
+
+ {creds.scope}
+
+
: null}
+
+
: null}
+
+ )
+ ) : (
+ No token found
+ )}
+
+ );
+};
+
+export default Oauth2TokenViewer;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/StyledWrapper.js
index 856f35b9b..b06deaedf 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/StyledWrapper.js
@@ -11,6 +11,47 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
+
+ .token-placement-selector {
+ padding: 0.5rem 0px;
+ border-radius: 3px;
+ border: solid 1px ${(props) => props.theme.input.border};
+ background-color: ${(props) => props.theme.input.bg};
+ min-width: 100px;
+
+ .dropdown {
+ width: fit-content;
+ min-width: 100px;
+
+ div[data-tippy-root] {
+ width: fit-content;
+ min-width: 100px;
+ }
+ .tippy-box {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+
+ .tippy-content: {
+ width: fit-content;
+ max-width: none !important;
+ min-width: 100px;
+ }
+ }
+ }
+
+ .token-placement-label {
+ width: fit-content;
+ // color: ${(props) => props.theme.colors.text.yellow};
+ justify-content: space-between;
+ padding: 0 0.5rem;
+ min-width: 100px;
+ }
+
+ .dropdown-item {
+ padding: 0.2rem 0.6rem !important;
+ }
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js
index 4ec8c1faa..f048183e6 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/PasswordCredentials/index.js
@@ -1,26 +1,66 @@
-import React from 'react';
+import React, { useRef, forwardRef } from 'react';
+import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
+import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHelp } from '@tabler/icons';
import SingleLineEditor from 'components/SingleLineEditor';
-import { updateAuth } from 'providers/ReduxStore/slices/collections';
-import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
+import Dropdown from 'components/Dropdown';
+import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
+import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
+import AdditionalParams from '../AdditionalParams/index';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
-const OAuth2AuthorizationCode = ({ item, collection }) => {
+const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+ const oAuth = get(request, 'auth.oauth2', {});
+ const { isSensitive } = useDetectSensitiveField(collection);
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
+ const {
+ accessTokenUrl,
+ username,
+ password,
+ clientId,
+ clientSecret,
+ scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken,
+ additionalParameters
+ } = oAuth;
- const handleRun = async () => {
- dispatch(sendRequest(item, collection.uid));
- };
+ const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
+ const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+ const handleSave = () => { save(); }
- const { accessTokenUrl, username, password, clientId, clientSecret, scope } = oAuth;
+ const TokenPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {tokenPlacement == 'url' ? 'URL' : 'Headers'}
+
+
+ );
+ });
+
+ const CredentialsPlacementIcon = forwardRef((props, ref) => {
+ return (
+
+ {credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
+
+
+ );
+ });
const handleChange = (key, value) => {
dispatch(
@@ -36,6 +76,15 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
clientId,
clientSecret,
scope,
+ credentialsPlacement,
+ credentialsId,
+ tokenPlacement,
+ tokenHeaderPrefix,
+ tokenQueryKey,
+ refreshTokenUrl,
+ autoRefreshToken,
+ autoFetchToken,
+ additionalParameters,
[key]: value
}
})
@@ -44,14 +93,26 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
return (
+
+
+
+
+
+
+ Configuration
+
+
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
+ const value = oAuth[key] || '';
+ const { showWarning, warningMessage } = isSensitive(value);
+
return (
-
-
{label}
-
+
+
{label}
+
handleChange(key, val)}
@@ -60,15 +121,191 @@ const OAuth2AuthorizationCode = ({ item, collection }) => {
item={item}
isSecret={isSecret}
/>
+ {isSecret && showWarning && }
);
})}
-
- Get Access Token
-
+
+
Add Credentials to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'body');
+ }}
+ >
+ Request Body
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('credentialsPlacement', 'basic_auth_header');
+ }}
+ >
+ Basic Auth Header
+
+
+
+
+
+
+
Token ID
+
+ handleChange('credentialsId', val)}
+ onRun={handleRun}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
Add token to
+
+
} placement="bottom-end">
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'header');
+ }}
+ >
+ Header
+
+
{
+ dropdownTippyRef.current.hide();
+ handleChange('tokenPlacement', 'url');
+ }}
+ >
+ URL
+
+
+
+
+ {
+ tokenPlacement === 'header' ?
+
+
Header Prefix
+
+ handleChange('tokenHeaderPrefix', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ :
+
+
Query Param Key
+
+ handleChange('tokenQueryKey', val)}
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+ }
+
+
+
+
+
+ Advanced Settings
+
+
+
+
+
Refresh Token URL
+
+ handleChange("refreshTokenUrl", val)}
+ collection={collection}
+ item={item}
+ />
+
+
+
+
+
+ {/* Automatically Fetch Token */}
+
+
handleChange('autoFetchToken', e.target.checked)}
+ className="cursor-pointer ml-1"
+ />
+
Automatically fetch token if not found
+
+
+
+
+ Automatically fetch a new token when you try to access a resource and don't have one.
+
+
+
+
+
+ {/* Auto Refresh Token (With Refresh URL) */}
+
+
handleChange('autoRefreshToken', e.target.checked)}
+ className={`cursor-pointer ml-1 ${isAutoRefreshDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
+ disabled={isAutoRefreshDisabled}
+ />
+
Auto refresh token (with refresh URL)
+
+
+
+
+ Automatically refresh your token using the refresh URL when it expires.
+
+
+
+
+
+
);
};
-export default OAuth2AuthorizationCode;
+export default OAuth2PasswordCredentials;
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js
index 3965c8d3e..98b435f1d 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/OAuth2/index.js
@@ -4,18 +4,39 @@ import StyledWrapper from './StyledWrapper';
import GrantTypeSelector from './GrantTypeSelector/index';
import OAuth2PasswordCredentials from './PasswordCredentials/index';
import OAuth2AuthorizationCode from './AuthorizationCode/index';
+import OAuth2Implicit from './Implicit/index';
import OAuth2ClientCredentials from './ClientCredentials/index';
+import { updateAuth } from 'providers/ReduxStore/slices/collections';
+import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { useDispatch } from 'react-redux';
+
+const GrantTypeComponentMap = ({ item, collection }) => {
+ const dispatch = useDispatch();
+
+ const save = () => {
+ dispatch(saveRequest(item.uid, collection.uid));
+ };
+
+ let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {});
+ const grantType = get(request, 'auth.oauth2.grantType', {});
+
+ const handleRun = async () => {
+ dispatch(sendRequest(item, collection.uid));
+ };
+
-const grantTypeComponentMap = (grantType, item, collection) => {
switch (grantType) {
case 'password':
- return
;
+ return
;
break;
case 'authorization_code':
- return
;
+ return
;
+ break;
+ case 'implicit':
+ return
;
break;
case 'client_credentials':
- return
;
+ return
;
break;
default:
return
TBD
;
@@ -24,12 +45,12 @@ const grantTypeComponentMap = (grantType, item, collection) => {
};
const OAuth2 = ({ item, collection }) => {
- const oAuth = item.draft ? get(item, 'draft.request.auth.oauth2', {}) : get(item, 'request.auth.oauth2', {});
+ let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {});
return (
-
- {grantTypeComponentMap(oAuth?.grantType, item, collection)}
+
+
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js
index 76a20e6f6..fde2310c9 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/WsseAuth/index.js
@@ -1,4 +1,6 @@
import React from 'react';
+import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
+import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
@@ -7,14 +9,19 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
-const WsseAuth = ({ item, collection }) => {
+const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
- const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
+ const wsseAuth = get(request, 'auth.wsse', {});
+ const { isSensitive } = useDetectSensitiveField(collection);
+ const { showWarning, warningMessage } = isSensitive(wsseAuth?.password);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
- const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const handleSave = () => {
+ save();
+ };
const handleUserChange = (username) => {
dispatch(
@@ -23,8 +30,8 @@ const WsseAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- username,
- password: wsseAuth.password
+ username: username || '',
+ password: wsseAuth.password || ''
}
})
);
@@ -37,8 +44,8 @@ const WsseAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
- username: wsseAuth.username,
- password
+ username: wsseAuth.username || '',
+ password: password || ''
}
})
);
@@ -55,11 +62,12 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handleUserChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
/>
Password
-
+
{
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
+ item={item}
+ isSecret={true}
/>
+ {showWarning && }
);
diff --git a/packages/bruno-app/src/components/RequestPane/Auth/index.js b/packages/bruno-app/src/components/RequestPane/Auth/index.js
index 743d23267..7ccbc3e7d 100644
--- a/packages/bruno-app/src/components/RequestPane/Auth/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Auth/index.js
@@ -7,71 +7,109 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
+import { updateAuth } from 'providers/ReduxStore/slices/collections';
+import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
-import { humanizeRequestAuthMode } from 'utils/collections/index';
+import { humanizeRequestAuthMode } from 'utils/collections';
import OAuth2 from './OAuth2/index';
+import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
+
+const getTreePathFromCollectionToItem = (collection, _item) => {
+ let path = [];
+ let item = findItemInCollection(collection, _item?.uid);
+ while (item) {
+ path.unshift(item);
+ item = findParentItemInCollection(collection, item?.uid);
+ }
+ return path;
+};
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+
+ // Create a request object to pass to the auth components
+ const request = item.draft
+ ? get(item, 'draft.request', {})
+ : get(item, 'request', {});
- const collectionRoot = get(collection, 'root', {});
- const collectionAuth = get(collectionRoot, 'request.auth');
+ // Save function for request level
+ const save = () => {
+ return saveRequest(item.uid, collection.uid);
+ };
+
+ const getEffectiveAuthSource = () => {
+ if (authMode !== 'inherit') return null;
+
+ const collectionAuth = get(collection, 'root.request.auth');
+ let effectiveSource = {
+ type: 'collection',
+ name: 'Collection',
+ auth: collectionAuth
+ };
+
+ // Check folders in reverse to find the closest auth configuration
+ for (let i of [...requestTreePath].reverse()) {
+ if (i.type === 'folder') {
+ const folderAuth = get(i, 'root.request.auth');
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
+ effectiveSource = {
+ type: 'folder',
+ name: i.name,
+ auth: folderAuth
+ };
+ break;
+ }
+ }
+ }
+
+ return effectiveSource;
+ };
const getAuthView = () => {
switch (authMode) {
case 'awsv4': {
- return
;
+ return
;
}
case 'basic': {
- return
;
+ return
;
}
case 'bearer': {
- return
;
+ return
;
}
case 'digest': {
- return
;
+ return
;
}
case 'ntlm': {
- return
;
+ return
;
}
case 'oauth2': {
- return
;
+ return
;
}
case 'wsse': {
- return
;
+ return
;
}
case 'apikey': {
- return
;
+ return
;
}
case 'inherit': {
+ const source = getEffectiveAuthSource();
return (
-
- {collectionAuth?.mode === 'oauth2' ? (
-
-
-
Collection level auth is:
-
{humanizeRequestAuthMode(collectionAuth?.mode)}
-
-
- Note: You need to use scripting to set the access token in the request headers.
-
-
- ) : (
- <>
-
Auth inherited from the Collection:
-
{humanizeRequestAuthMode(collectionAuth?.mode)}
- >
- )}
-
+ <>
+
+
Auth inherited from {source.name}:
+
{humanizeRequestAuthMode(source.auth?.mode)}
+
+ >
);
}
}
};
return (
-
+
diff --git a/packages/bruno-app/src/components/RequestPane/FileBody/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/FileBody/StyledWrapper.js
new file mode 100644
index 000000000..35adfcc1f
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/FileBody/StyledWrapper.js
@@ -0,0 +1,65 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ td {
+ padding: 6px 10px;
+
+ &:nth-child(1) {
+ width: 30%;
+ }
+
+ &:nth-child(2) {
+ width: 45%;
+ }
+
+ &:nth-child(3) {
+ width: 25%;
+ }
+
+ &:nth-child(4) {
+ width: 70px;
+ }
+ }
+ }
+
+ .btn-add-param {
+ font-size: 0.8125rem;
+ }
+
+ input[type='text'] {
+ width: 100%;
+ border: solid 1px transparent;
+ outline: none !important;
+ color: ${(props) => props.theme.table.input.color};
+ background: transparent;
+
+ &:focus {
+ outline: none !important;
+ border: solid 1px transparent;
+ }
+ }
+
+ input[type='radio'] {
+ cursor: pointer;
+ position: relative;
+ top: 1px;
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/FileBody/index.js b/packages/bruno-app/src/components/RequestPane/FileBody/index.js
new file mode 100644
index 000000000..d97953aa5
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/FileBody/index.js
@@ -0,0 +1,164 @@
+import React, { useState, useEffect } from 'react';
+import { get, cloneDeep, isArray } from 'lodash';
+import { IconTrash } from '@tabler/icons';
+import { useDispatch } from 'react-redux';
+import { useTheme } from 'providers/Theme';
+import { addFile as _addFile, updateFile, deleteFile } from 'providers/ReduxStore/slices/collections/index';
+import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import StyledWrapper from './StyledWrapper';
+import FilePickerEditor from 'components/FilePickerEditor/index';
+import SingleLineEditor from 'components/SingleLineEditor/index';
+
+const FileBody = ({ item, collection }) => {
+ const dispatch = useDispatch();
+ const { storedTheme } = useTheme();
+ const params = item.draft ? get(item, 'draft.request.body.file') : get(item, 'request.body.file');
+
+ const [enabledFileUid, setEnableFileUid] = useState(params && params.length ? params[0].uid : '');
+
+ const addFile = () => {
+ dispatch(
+ _addFile({
+ itemUid: item.uid,
+ collectionUid: collection.uid,
+ })
+ );
+ };
+
+ const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
+ const handleRun = () => dispatch(sendRequest(item, collection.uid));
+
+ const handleParamChange = (e, _param, type) => {
+ const param = cloneDeep(_param);
+ switch (type) {
+ case 'filePath': {
+ param.filePath = e.target.filePath;
+ param.contentType = "";
+ break;
+ }
+ case 'contentType': {
+ param.contentType = e.target.contentType;
+ break;
+ }
+ case 'selected': {
+ param.selected = e.target.selected;
+ setEnableFileUid(param.uid)
+ break;
+ }
+ }
+ dispatch(
+ updateFile({
+ param: param,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ const handleRemoveParams = (param) => {
+ dispatch(
+ deleteFile({
+ paramUid: param.uid,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ return (
+
+
+
+
+
+ File
+
+
+ Content-Type
+
+
+ Selected
+
+
+
+
+
+ {params && params.length
+ ? params.map((param, index) => {
+ return (
+
+
+
+ handleParamChange(
+ {
+ target: {
+ filePath: path
+ }
+ },
+ param,
+ 'filePath'
+ )
+ }
+ collection={collection}
+ />
+
+
+
+ handleParamChange(
+ {
+ target: {
+ contentType: newValue
+ }
+ },
+ param,
+ 'contentType'
+ )
+ }
+ onRun={handleRun}
+ collection={collection}
+ />
+
+
+
+ handleParamChange(e, param, 'selected')}
+ />
+
+
+
+
+ handleRemoveParams(param)}>
+
+
+
+
+
+ );
+ })
+ : null}
+
+
+
+
+ + Add File
+
+
+
+ );
+};
+export default FileBody;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
index 187a91a68..da48bb34a 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLRequestPane/index.js
@@ -18,8 +18,10 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
+import HeightBoundContainer from 'ui/HeightBoundContainer';
+import Settings from 'components/RequestPane/Settings';
-const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
+const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -66,7 +68,6 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
collection={collection}
theme={displayedTheme}
schema={schema}
- width={leftPaneWidth}
onSave={onSave}
value={query}
onRun={onRun}
@@ -101,6 +102,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
case 'docs': {
return ;
}
+ case 'settings': {
+ return ;
+ }
default: {
return 404 | Not found
;
}
@@ -152,9 +156,14 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
selectTab('docs')}>
Docs
+ selectTab('settings')}>
+ Settings
+
-
{getTabPanel(focusedTab.requestPaneTab)}
+
+ {getTabPanel(focusedTab.requestPaneTab)}
+
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js
index 8fe747389..3b1cc6109 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLSchemaActions/index.js
@@ -7,8 +7,10 @@ import Dropdown from '../../Dropdown';
const GraphQLSchemaActions = ({ item, collection, onSchemaLoad, toggleDocs }) => {
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
+ const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', '');
+ const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', '');
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
- const request = item.draft ? item.draft.request : item.request;
+ const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid };
let {
schema,
diff --git a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
index 91fea0134..0a7fd98c9 100644
--- a/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
+++ b/packages/bruno-app/src/components/RequestPane/GraphQLVariables/index.js
@@ -49,7 +49,7 @@ const GraphQLVariables = ({ variables, item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
-
+ <>
{
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
- mode="javascript"
+ mode="application/json"
onRun={onRun}
onSave={onSave}
+ enableVariableHighlighting={true}
+ showHintsFor={['variables']}
/>
-
+ >
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcBody/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcBody/StyledWrapper.js
new file mode 100644
index 000000000..4b1ebcdb1
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/GrpcBody/StyledWrapper.js
@@ -0,0 +1,59 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ flex: 1;
+ /* height: 100%; */
+ position: relative;
+
+ .grpc-message-header {
+ .font-medium {
+ color: ${(props) => props.theme.text};
+ }
+
+ button {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+
+ &:hover {
+ transform: scale(1.1);
+ }
+
+ &:active {
+ transform: scale(0.95);
+ }
+ }
+ }
+
+ #grpc-messages-container {
+ /* height: 100%; */
+ position: relative;
+ }
+
+ .add-message-btn-container {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ padding-top: 8px;
+ background: ${(props) => props.theme.bg || '#fff'};
+ z-index: 15;
+ border-top: 1px solid ${(props) => props.theme.border || 'rgba(0, 0, 0, 0.1)'};
+
+ .add-message-btn {
+ width: 100%;
+ }
+ }
+
+ .CodeMirror {
+ border-top: 0;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+`;
+
+export default Wrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js
new file mode 100644
index 000000000..ce7cdebd9
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/GrpcBody/index.js
@@ -0,0 +1,354 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { get } from 'lodash';
+import { useDispatch, useSelector } from 'react-redux';
+import { useTheme } from 'providers/Theme';
+import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
+import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { sendGrpcMessage, generateGrpcSampleMessage } from 'utils/network/index';
+import useLocalStorage from 'hooks/useLocalStorage';
+
+import CodeEditor from 'components/CodeEditor/index';
+import StyledWrapper from './StyledWrapper';
+import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash, IconChevronDown, IconChevronUp } from '@tabler/icons';
+import ToolHint from 'components/ToolHint/index';
+import { toastError } from 'utils/common/error';
+import { format, applyEdits } from 'jsonc-parser';
+import toast from 'react-hot-toast'
+import { getAbsoluteFilePath } from 'utils/common/path';
+
+const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
+ const dispatch = useDispatch();
+ const { displayedTheme, theme } = useTheme();
+ const preferences = useSelector((state) => state.app.preferences);
+ const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
+ const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
+
+ // Access gRPC method metadata from local storage
+ const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
+ const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
+
+ const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
+
+ const { name, content } = message;
+
+ const onEdit = (value) => {
+ const currentMessages = [...(body.grpc || [])];
+
+ currentMessages[index] = {
+ name: name ? name : `message ${index + 1}`,
+ content: value
+ };
+
+ dispatch(
+ updateRequestBody({
+ content: currentMessages,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ const onSend = async () => {
+ try {
+ await sendGrpcMessage(item, collection.uid, content);
+ } catch (error) {
+ console.error('Error sending message:', error);
+ }
+ }
+ const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
+
+ const onRegenerateMessage = async () => {
+ try {
+ const methodPath = item.draft?.request?.method || item.request?.method;
+
+ if (!methodPath) {
+ toastError(new Error('Method path not found in request'));
+ return;
+ }
+
+ // Get the URL and protoPath to determine which cache to use
+ const url = item.draft?.request?.url || item.request?.url;
+ const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
+
+ // Find the method metadata from the appropriate cache
+ let methodMetadata = null;
+ if (protoPath) {
+ // Use protofile cache if protoPath is available
+ const absolutePath = getAbsoluteFilePath(protoPath, collection.pathname);
+ const cachedMethods = protofileCache[absolutePath];
+ if (cachedMethods) {
+ methodMetadata = cachedMethods.find(method => method.path === methodPath);
+ }
+ } else if (url) {
+ // Use reflection cache if no protoPath (reflection mode)
+ const cachedMethods = reflectionCache[url];
+ if (cachedMethods) {
+ methodMetadata = cachedMethods.find(method => method.path === methodPath);
+ }
+ }
+
+ const result = await generateGrpcSampleMessage(
+ methodPath,
+ content,
+ {
+ arraySize: 2,
+ methodMetadata // Pass the method metadata to the function
+ }
+ );
+
+ if (result.success) {
+ const currentMessages = [...(body.grpc || [])];
+
+ currentMessages[index] = {
+ name: name ? name : `message ${index + 1}`,
+ content: result.message
+ };
+
+ dispatch(
+ updateRequestBody({
+ content: currentMessages,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+
+ toast.success('Sample message generated successfully!');
+ } else {
+ toastError(new Error(result.error || 'Failed to generate sample message'));
+ }
+ } catch (error) {
+ console.error('Error generating sample message:', error);
+ toastError(error);
+ }
+ };
+
+ const onDeleteMessage = () => {
+ const currentMessages = [...(body.grpc || [])];
+
+ currentMessages.splice(index, 1);
+
+ dispatch(
+ updateRequestBody({
+ content: currentMessages,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ const onPrettify = () => {
+ try {
+ const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
+ const prettyBodyJson = applyEdits(content, edits);
+
+ const currentMessages = [...(body.grpc || [])];
+ currentMessages[index] = {
+ name: name ? name : `message ${index + 1}`,
+ content: prettyBodyJson
+ };
+ dispatch(
+ updateRequestBody({
+ content: currentMessages,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ } catch (e) {
+ toastError(new Error('Unable to prettify. Invalid JSON format.'));
+ }
+ };
+
+ const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? "" : "h-80"}` : "h-full"
+
+ return (
+
+
+
+ {isCollapsed ?
+ :
+
+ }
+ {`Message ${canClientStream ? index + 1 : ''}`}
+
+
e.stopPropagation()}>
+
+
+
+
+
+
+
+
+
+
+
+
+ {canClientStream && (
+
+
+
+
+
+ )}
+
+ {index > 0 && (
+
+
+
+
+
+ )}
+
+
+
+ {!isCollapsed && (
+
+
+
+ )}
+
+ )
+}
+
+const GrpcBody = ({ item, collection, handleRun }) => {
+ const preferences = useSelector((state) => state.app.preferences);
+ const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
+ const dispatch = useDispatch();
+ const [collapsedMessages, setCollapsedMessages] = useState([]);
+ const messagesContainerRef = useRef(null);
+ const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
+
+ const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
+ const canClientSendMultipleMessages = methodType === 'client-streaming' || methodType === 'bidi-streaming';
+
+ // Auto-scroll to the latest message when messages are added
+ useEffect(() => {
+ if (messagesContainerRef.current && body?.grpc?.length > 0) {
+ const container = messagesContainerRef.current;
+ container.scrollTop = container.scrollHeight;
+ }
+ }, [body?.grpc?.length]);
+
+ const toggleMessageCollapse = (index) => {
+ setCollapsedMessages(prev => {
+ if (prev.includes(index)) {
+ return prev.filter(i => i !== index);
+ } else {
+ return [...prev, index];
+ }
+ });
+ };
+
+ const addNewMessage = () => {
+ const currentMessages = Array.isArray(body.grpc)
+ ? [...body.grpc]
+ : [];
+
+ currentMessages.push({
+ name: `message ${currentMessages.length + 1}`,
+ content: '{}'
+ });
+
+ dispatch(
+ updateRequestBody({
+ content: currentMessages,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+
+ if (!body?.grpc || !Array.isArray(body.grpc)) {
+ return (
+
+
+
No gRPC messages available
+
+
+
+ Add First Message
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {body.grpc
+ .filter((_, index) => canClientSendMultipleMessages || index === 0)
+ .map((message, index) => (
+ toggleMessageCollapse(index)}
+ handleRun={handleRun}
+ canClientSendMultipleMessages={canClientSendMultipleMessages}
+ />
+ ))}
+
+
+ {canClientSendMultipleMessages && (
+
+
+
+
+ Add Message
+
+
+
+ )}
+
+ );
+};
+
+export default GrpcBody;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/StyledWrapper.js
new file mode 100644
index 000000000..64c29be51
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/StyledWrapper.js
@@ -0,0 +1,119 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ height: 2.3rem;
+
+ .method-selector-container {
+ background-color: ${(props) => props.theme.requestTabPanel.url.bg};
+ border-top-left-radius: 3px;
+ border-bottom-left-radius: 3px;
+ }
+
+ .input-container {
+ background-color: ${(props) => props.theme.requestTabPanel.url.bg};
+ border-top-right-radius: 3px;
+ border-bottom-right-radius: 3px;
+
+ input {
+ background-color: ${(props) => props.theme.requestTabPanel.url.bg};
+ outline: none;
+ box-shadow: none;
+
+ &:focus {
+ outline: none !important;
+ box-shadow: none !important;
+ }
+ }
+ }
+
+ .caret {
+ color: rgb(140, 140, 140);
+ fill: rgb(140 140 140);
+ position: relative;
+ top: 1px;
+ }
+
+ .infotip {
+ position: relative;
+ display: inline-block;
+ cursor: pointer;
+ }
+
+ .infotip:hover .infotip-text {
+ visibility: visible;
+ opacity: 1;
+ }
+
+ .infotip-text {
+ visibility: hidden;
+ width: auto;
+ background-color: ${(props) => props.theme.requestTabs.active.bg};
+ color: ${(props) => props.theme.text};
+ text-align: center;
+ border-radius: 4px;
+ padding: 4px 8px;
+ position: absolute;
+ z-index: 1;
+ bottom: 34px;
+ left: 50%;
+ transform: translateX(-50%);
+ opacity: 0;
+ transition: opacity 0.3s;
+ white-space: nowrap;
+ }
+
+ .infotip-text::after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ margin-left: -4px;
+ border-width: 4px;
+ border-style: solid;
+ border-color: ${(props) => props.theme.requestTabs.active.bg} transparent transparent transparent;
+ }
+
+ .shortcut {
+ font-size: 0.625rem;
+ }
+
+ @keyframes pulse {
+ 0% {
+ opacity: 0.4;
+ }
+ 50% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0.4;
+ }
+ }
+
+ .connection-status-strip {
+ animation: pulse 1.5s ease-in-out infinite;
+ background-color: ${(props) => props.theme.colors.text.green};
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ }
+
+ /* Method dropdown styling */
+ .method-dropdown {
+ margin-right: 8px;
+ position: relative;
+ z-index: 10;
+ }
+
+ .dropdown-item {
+ padding: 8px 12px;
+ cursor: pointer;
+
+ &:hover {
+ background-color: ${(props) => props.theme.dropdown.hoverBg};
+ }
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js
new file mode 100644
index 000000000..150736ae3
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/GrpcQueryUrl/index.js
@@ -0,0 +1,1028 @@
+import React, { useState, useEffect, useRef, forwardRef, useCallback, useMemo } from 'react';
+import get from 'lodash/get';
+import { useDispatch, useSelector } from 'react-redux';
+import { requestUrlChanged, updateRequestMethod, updateRequestProtoPath } from 'providers/ReduxStore/slices/collections';
+import { saveRequest, browseFiles, loadGrpcMethodsFromReflection, openCollectionSettings, generateGrpcurlCommand } from 'providers/ReduxStore/slices/collections/actions';
+import { useTheme } from 'providers/Theme';
+import SingleLineEditor from 'components/SingleLineEditor/index';
+import { isMacOS } from 'utils/common/platform';
+import { getRelativePath, getBasename, getAbsoluteFilePath } from 'utils/common/path';
+import useLocalStorage from 'hooks/useLocalStorage/index';
+import StyledWrapper from './StyledWrapper';
+import ToggleSwitch from 'components/ToggleSwitch/index';
+import {
+ IconX,
+ IconCheck,
+ IconRefresh,
+ IconDeviceFloppy,
+ IconArrowRight,
+ IconCode,
+ IconFile,
+ IconChevronDown,
+ IconSettings,
+ IconAlertCircle,
+ IconCopy
+} from '@tabler/icons';
+import toast from 'react-hot-toast';
+import {
+ loadGrpcMethodsFromProtoFile,
+ cancelGrpcConnection,
+ endGrpcConnection
+} from 'utils/network/index';
+import Dropdown from 'components/Dropdown/index';
+import {
+ IconGrpcUnary,
+ IconGrpcClientStreaming,
+ IconGrpcServerStreaming,
+ IconGrpcBidiStreaming
+} from 'components/Icons/Grpc';
+import Modal from 'components/Modal/index';
+import CodeEditor from 'components/CodeEditor';
+import { debounce } from 'lodash';
+import { getPropertyFromDraftOrRequest } from 'utils/collections';
+import { existsSync } from 'utils/filesystem';
+
+// Constants for gRPC method types
+const STREAMING_METHOD_TYPES = ['client-streaming', 'server-streaming', 'bidi-streaming'];
+const CLIENT_STREAMING_METHOD_TYPES = ['client-streaming', 'bidi-streaming'];
+
+const GrpcurlModal = ({ isOpen, onClose, command }) => {
+ const { displayedTheme } = useTheme();
+ const [copied, setCopied] = useState(false);
+ const preferences = useSelector((state) => state.app.preferences);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(command);
+ setCopied(true);
+ toast.success('Command copied to clipboard');
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ toast.error('Failed to copy command');
+ }
+ };
+
+ return (
+
+ Generate gRPCurl Command
+ BETA
+
+ }
+ size="lg"
+ hideFooter={true}
+ >
+
+
+
+
+
+ {copied ? : }
+
+
+
+
+
+
+
+ );
+};
+
+const GrpcQueryUrl = ({ item, collection, handleRun }) => {
+ const { theme, storedTheme } = useTheme();
+ const dispatch = useDispatch();
+ const method = getPropertyFromDraftOrRequest(item, 'request.method');
+ const type = getPropertyFromDraftOrRequest(item, 'request.type');
+ const url = getPropertyFromDraftOrRequest(item, 'request.url', '');
+ const protoPath = getPropertyFromDraftOrRequest(item, 'request.protoPath');
+ const isMac = isMacOS();
+ const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
+ const editorRef = useRef(null);
+ const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
+ const [protoFilePath, setProtoFilePath] = useState(protoPath);
+ const [grpcMethods, setGrpcMethods] = useState([]);
+ const [isLoadingMethods, setIsLoadingMethods] = useState(false);
+ const [selectedGrpcMethod, setSelectedGrpcMethod] = useState({
+ path: method,
+ type: type
+ });
+ const methodDropdownRef = useRef();
+ const protoDropdownRef = useRef();
+ const haveFetchedMethodsRef = useRef(false);
+ const [showGrpcurlModal, setShowGrpcurlModal] = useState(false);
+ const [grpcurlCommand, setGrpcurlCommand] = useState('');
+ const [isReflectionMode, setIsReflectionMode] = useState(false);
+ const collectionProtoFiles = get(collection, 'brunoConfig.grpc.protoFiles', []);
+ const [reflectionCache, setReflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
+ const [protofileCache, setProtofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
+ const fileExistsCache = useRef(new Map());
+ const [showProtoDropdown, setShowProtoDropdown] = useState(false);
+
+ const fileExists = useCallback(async (filePath) => {
+ if (!filePath) return false;
+
+ if (fileExistsCache.current.has(filePath)) {
+ return fileExistsCache.current.get(filePath);
+ }
+
+ try {
+ const absolutePath = getAbsoluteFilePath(filePath, collection.pathname);
+ const exists = await existsSync(absolutePath);
+ fileExistsCache.current.set(filePath, exists);
+ return exists;
+ } catch (error) {
+ console.error('Error checking if file exists:', error);
+ return false;
+ }
+ }, [collection.pathname]);
+
+ const [collectionProtoFilesExistence, setCollectionProtoFilesExistence] = useState([]);
+
+ useEffect(() => {
+ const fetchCollectionProtoFilesExistence = async () => {
+ if (!collectionProtoFiles) return;
+ const existence = await Promise.all(collectionProtoFiles.map(async (protoFile) => {
+ const absolutePath = getAbsoluteFilePath(protoFile.path, collection.pathname);
+ const exists = await fileExists(absolutePath)
+ return {
+ path: protoFile.path,
+ absolutePath,
+ exists
+ }
+ }));
+ setCollectionProtoFilesExistence(existence);
+ };
+ fetchCollectionProtoFilesExistence();
+ }, [fileExists]);
+
+ const invalidProtoFiles = useMemo(() => {
+ return collectionProtoFilesExistence.filter(file => !file.exists);
+ }, [collectionProtoFilesExistence]);
+
+ const currentProtoFileExists = useMemo(() => {
+ return fileExists(protoFilePath);
+ }, [protoFilePath, fileExists]);
+
+ const onMethodDropdownCreate = (ref) => (methodDropdownRef.current = ref);
+ const onProtoDropdownCreate = (ref) => (protoDropdownRef.current = ref);
+
+
+ const isStreamingMethod = selectedGrpcMethod && selectedGrpcMethod.type && STREAMING_METHOD_TYPES.includes(selectedGrpcMethod.type);
+ const isClientStreamingMethod = selectedGrpcMethod && selectedGrpcMethod.type && CLIENT_STREAMING_METHOD_TYPES.includes(selectedGrpcMethod.type);
+
+ const onSave = (finalValue) => {
+ dispatch(saveRequest(item.uid, collection.uid));
+ };
+
+ const onUrlChange = (value) => {
+ if (!editorRef.current?.editor) return;
+ const editor = editorRef.current.editor;
+ const cursor = editor.getCursor();
+
+ const finalUrl = value?.trim() || value;
+
+ dispatch(
+ requestUrlChanged({
+ itemUid: item.uid,
+ collectionUid: collection.uid,
+ url: finalUrl
+ })
+ );
+
+ // Restore cursor position only if URL was trimmed
+ if (finalUrl !== value) {
+ setTimeout(() => {
+ if (editor) {
+ editor.setCursor(cursor);
+ }
+ }, 0);
+ }
+
+ if(!protoFilePath && value) {
+ setIsReflectionMode(true);
+ handleReflection(finalUrl);
+ }
+ };
+
+ const onMethodSelect = ({ path, type }) => {
+ if (isConnectionActive) {
+ cancelGrpcConnection(item.uid)
+ .then(() => {
+ toast.success('gRPC connection cancelled');
+ })
+ .catch((err) => {
+ console.error('Failed to cancel gRPC connection:', err);
+ });
+ }
+
+ dispatch(
+ updateRequestMethod({
+ method: path,
+ methodType: type,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ };
+
+ const handleReflection = async (url, isManualRefresh = false) => {
+ if (!url) return;
+
+ const cachedMethods = reflectionCache[url];
+ if (!isManualRefresh && cachedMethods && !isLoadingMethods) {
+ setGrpcMethods(cachedMethods);
+ setProtoFilePath('');
+ setIsReflectionMode(true);
+ const isDuplicateSave = !item.request.protoPath;
+ if (!isDuplicateSave) {
+ dispatch(updateRequestProtoPath({
+ protoPath: '',
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ }));
+ }
+
+ if (cachedMethods && cachedMethods.length > 0) {
+ const haveSelectedMethod =
+ selectedGrpcMethod && cachedMethods.some((method) => method.path === selectedGrpcMethod.path);
+ if (!haveSelectedMethod) {
+ setSelectedGrpcMethod(null);
+ onMethodSelect({ path: '', type: '' });
+ } else if (selectedGrpcMethod) {
+ // Update the method type for the currently selected method to ensure it matches
+ const currentMethod = cachedMethods.find((method) => method.path === selectedGrpcMethod.path);
+ if (currentMethod) {
+ const methodType = currentMethod.type;
+ setSelectedGrpcMethod({
+ path: selectedGrpcMethod.path,
+ type: methodType
+ });
+ }
+ }
+ return;
+ }
+ }
+
+ setIsLoadingMethods(true);
+ try {
+ const { methods, error } = await dispatch(loadGrpcMethodsFromReflection(item, collection.uid, url));
+
+ if (error) {
+ console.error('Error loading gRPC methods:', error);
+ toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`);
+ return;
+ }
+
+ // Cache the methods for this URL
+ setReflectionCache(prevCache => ({
+ ...prevCache,
+ [url]: methods
+ }));
+
+ setGrpcMethods(methods);
+ setProtoFilePath('');
+ setIsReflectionMode(true);
+ const isDuplicateSave = !item.request.protoPath;
+ if (!isDuplicateSave) {
+ dispatch(updateRequestProtoPath({
+ protoPath: '',
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ }));
+ }
+
+ if (methods && methods.length > 0) {
+ const haveSelectedMethod =
+ selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path);
+ if (!haveSelectedMethod) {
+ setSelectedGrpcMethod(null);
+ onMethodSelect({ path: '', type: '' });
+ } else if (selectedGrpcMethod) {
+ // Update the method type for the currently selected method to ensure it matches
+ const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path);
+ if (currentMethod) {
+ const methodType = currentMethod.type;
+ setSelectedGrpcMethod({
+ path: selectedGrpcMethod.path,
+ type: methodType
+ });
+ }
+ }
+ toast.success(`Loaded ${methods.length} gRPC methods from reflection`);
+ }
+ } catch (error) {
+ console.error('Error loading gRPC methods:', error);
+ toast.error('Failed to load gRPC methods from reflection');
+ } finally {
+ setIsLoadingMethods(false);
+ }
+ };
+
+ const handleGrpcurl = async (url) => {
+ if (!url) {
+ toast.error('Please enter a valid gRPC server URL');
+ return;
+ }
+
+ if (!selectedGrpcMethod?.path) {
+ toast.error('Please select a gRPC method');
+ return;
+ }
+
+ try {
+ const result = await dispatch(generateGrpcurlCommand(item, collection.uid));
+
+ if (result.success) {
+ setGrpcurlCommand(result.command);
+ setShowGrpcurlModal(true);
+ } else {
+ toast.error(result.error || 'Failed to generate grpcurl command');
+ }
+ } catch (error) {
+ console.error('Error generating grpcurl command:', error);
+ toast.error('Failed to generate grpcurl command');
+ }
+ };
+
+ // Add a new function to group methods by service
+ const groupMethodsByService = (methods) => {
+ if (!methods || !methods.length) return {};
+
+ const groupedMethods = {};
+
+ methods.forEach(method => {
+ // The format is "/service.ServiceName/MethodName"
+ const pathWithoutLeadingSlash = method.path.startsWith('/') ? method.path.slice(1) : method.path;
+ const parts = pathWithoutLeadingSlash.split('/');
+
+ // The service is the part before the last slash
+ const serviceName = parts[0] || 'Default';
+ // The method name is the part after the last slash
+ const methodName = parts[1] || method.path;
+
+ // Store the extracted method name for easier display
+ const enhancedMethod = {
+ ...method,
+ serviceName,
+ methodName
+ };
+
+ if (!groupedMethods[serviceName]) {
+ groupedMethods[serviceName] = [];
+ }
+
+ groupedMethods[serviceName].push(enhancedMethod);
+ });
+
+ return groupedMethods;
+ };
+
+ const MethodsDropdownIcon = forwardRef((props, ref) => {
+ return (
+
+ {selectedGrpcMethod &&
{getIconForMethodType(selectedGrpcMethod.type)}
}
+
+ {selectedGrpcMethod ? (
+
+ {selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path}
+
+ ) : (
+ Select Method
+ )}
+
+
+
+ );
+ });
+
+ const ProtoFileDropdownIcon = forwardRef((props, ref) => {
+ return (
+ setShowProtoDropdown(prev => !prev)}>
+ {isReflectionMode ? (<>>
+ ) : (
+
+ )}
+
+ {isReflectionMode ? 'Using Reflection' : (protoFilePath ? getBasename(protoFilePath) : 'Select Proto File')}
+
+
+
+ );
+ });
+
+ const handleGrpcMethodSelect = (method) => {
+ const methodType = method.type
+ setSelectedGrpcMethod({
+ path: method.path,
+ type: methodType
+ });
+ onMethodSelect({ path: method.path, type: methodType });
+ };
+
+ const getIconForMethodType = (type) => {
+ switch (type) {
+ case 'unary':
+ return ;
+ case 'client-streaming':
+ return ;
+ case 'server-streaming':
+ return ;
+ case 'bidi-streaming':
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const handleCancelConnection = (e) => {
+ e.stopPropagation();
+
+ cancelGrpcConnection(item.uid)
+ .then(() => {
+ toast.success('gRPC connection cancelled');
+ })
+ .catch((err) => {
+ console.error('Failed to cancel gRPC connection:', err);
+ toast.error('Failed to cancel gRPC connection');
+ });
+ };
+
+ const handleEndConnection = (e) => {
+ e.stopPropagation();
+
+ endGrpcConnection(item.uid)
+ .then(() => {
+ toast.success('gRPC stream ended');
+ })
+ .catch((err) => {
+ console.error('Failed to end gRPC stream:', err);
+ toast.error('Failed to end gRPC stream');
+ });
+ };
+
+ const handleSelectCollectionProtoFile = (protoFile) => {
+ try {
+ if (!protoFile) {
+ toast.error('No proto file selected');
+ return;
+ }
+
+ // Get the absolute path from the relative path
+ const absolutePath = protoFile.absolutePath;
+
+ if (!protoFile.exists) {
+ toast.error(`Proto file not found: ${protoFile.path}`);
+ return;
+ }
+
+ setProtoFilePath(protoFile.path);
+ setIsReflectionMode(false);
+
+ dispatch(updateRequestProtoPath({
+ protoPath: protoFile.path,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ }));
+
+ loadMethodsFromProtoFile(absolutePath);
+ } catch (error) {
+ console.error('Error selecting collection proto file:', error);
+ toast.error('Failed to select collection proto file');
+ }
+ };
+
+ const handleResetProtoFile = () => {
+ setProtoFilePath('');
+ setIsReflectionMode(true);
+ const isDuplicateSave = !item.request.protoPath;
+ if (!isDuplicateSave) {
+ dispatch(updateRequestProtoPath({
+ protoPath: '',
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ }));
+ }
+ setGrpcMethods([]);
+ setSelectedGrpcMethod(null);
+ onMethodSelect({ path: '', type: '' });
+ toast.success('Proto file reset');
+ };
+
+ const loadMethodsFromProtoFile = async (filePath, isManualRefresh = false) => {
+ if (!filePath) {
+ toast.error('No proto file selected');
+ return;
+ };
+ const absolutePath = getAbsoluteFilePath(filePath, collection.pathname);
+
+ // Check if we have cached methods for this proto file
+ const cachedMethods = protofileCache[absolutePath];
+ if (cachedMethods && !isLoadingMethods && !isManualRefresh) {
+ setGrpcMethods(cachedMethods);
+
+ if (cachedMethods && cachedMethods.length > 0) {
+ // Check if currently selected method is still valid
+ const haveSelectedMethod =
+ selectedGrpcMethod && cachedMethods.some((method) => method.path === selectedGrpcMethod.path);
+ if (!haveSelectedMethod) {
+ setSelectedGrpcMethod(null);
+ onMethodSelect({ path: '', type: '' });
+ } else {
+ // Update the method type for the currently selected method to ensure it matches
+ const currentMethod = cachedMethods.find((method) => method.path === selectedGrpcMethod.path);
+ if (currentMethod) {
+ const methodType = currentMethod.type;
+ setSelectedGrpcMethod({
+ path: selectedGrpcMethod.path,
+ type: methodType
+ });
+ }
+ }
+ }
+ return;
+ }
+
+ setIsLoadingMethods(true);
+ try {
+ const { methods, error } = await loadGrpcMethodsFromProtoFile(absolutePath);
+
+ if (error) {
+ console.error('Error loading gRPC methods:', error);
+ toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`);
+ return;
+ }
+
+ // Cache the methods for this proto file
+ setProtofileCache(prevCache => ({
+ ...prevCache,
+ [absolutePath]: methods
+ }));
+
+ setGrpcMethods(methods);
+
+ if (methods && methods.length > 0) {
+ toast.success(`Loaded ${methods.length} gRPC methods from proto file`);
+
+ // Check if currently selected method is still valid
+ const haveSelectedMethod =
+ selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path);
+ if (!haveSelectedMethod) {
+ setSelectedGrpcMethod(null);
+ onMethodSelect({ path: '', type: '' });
+ } else {
+ // Update the method type for the currently selected method to ensure it matches
+ const currentMethod = methods.find((method) => method.path === selectedGrpcMethod.path);
+ if (currentMethod) {
+ const methodType = currentMethod.type;
+ setSelectedGrpcMethod({
+ path: selectedGrpcMethod.path,
+ type: methodType
+ });
+ }
+ }
+ } else {
+ toast.warning('No gRPC methods found in proto file');
+ }
+ } catch (err) {
+ console.error('Error loading gRPC methods:', err);
+ toast.error('Failed to load gRPC methods from proto file');
+ } finally {
+ setIsLoadingMethods(false);
+ }
+ };
+
+ const handleSelectProtoFile = (e) => {
+ e.stopPropagation();
+ const filters = [{ name: 'Proto Files', extensions: ['proto'] }];
+
+ dispatch(browseFiles(filters, ['']))
+ .then((filePaths) => {
+ if (filePaths && filePaths.length > 0) {
+ const filePath = filePaths[0];
+ const relativePath = getRelativePath(filePath, collection.pathname);
+ setProtoFilePath(relativePath);
+ setIsReflectionMode(false);
+
+ dispatch(updateRequestProtoPath({
+ protoPath: relativePath,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ }));
+
+ // Load methods from the newly selected proto file
+ const absolutePath = getAbsoluteFilePath(relativePath, collection.pathname);
+ loadMethodsFromProtoFile(absolutePath);
+ }
+ })
+ .catch((err) => {
+ console.error('Error selecting proto file:', err);
+ toast.error('Failed to select proto file');
+ });
+ };
+
+ const handleOpenCollectionGrpc = () => {
+ dispatch(openCollectionSettings(collection.uid, 'grpc'));
+ };
+
+ const debouncedOnUrlChange = debounce(onUrlChange, 1000);
+
+ useEffect(() => {
+ fileExistsCache.current.clear();
+ }, [collection.pathname]);
+
+ useEffect(() => {
+ if(haveFetchedMethodsRef.current) {
+ return;
+ }
+ haveFetchedMethodsRef.current = true;
+
+ if(protoFilePath) {
+ setIsReflectionMode(false);
+ loadMethodsFromProtoFile(protoFilePath);
+ return;
+ }
+ if (!url) return;
+ setIsReflectionMode(true);
+ handleReflection(url);
+
+ }, []);
+
+ return (
+
+
+
+
onSave(finalValue)}
+ theme={storedTheme}
+ onChange={(newValue) => debouncedOnUrlChange(newValue)}
+ onRun={handleRun}
+ collection={collection}
+ highlightPathParams={true}
+ item={item}
+ />
+
+ {grpcMethods && grpcMethods.length > 0 && (
+
+
} placement="bottom-end" style={{ maxWidth: "unset" }}>
+
+ {Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => (
+
+
+ {serviceName || 'Default Service'}
+
+
+ {methods.map((method, methodIndex) => (
+
handleGrpcMethodSelect(method)}
+ >
+
+
{getIconForMethodType(method.type)}
+
+
{method.methodName}
+
{method.type}
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+ )}
+
+
+
}
+ placement="bottom-end"
+ visible={showProtoDropdown}
+ onClickOutside={() => setShowProtoDropdown(false)}
+ >
+
+
+
{isReflectionMode ? "Using Reflection" : "Select Proto File"}
+
+
+ {/* Mode Toggle */}
+
+
+
Mode
+
+
+ Proto File
+
+ {
+ e.stopPropagation();
+ e.preventDefault();
+ setIsReflectionMode(!isReflectionMode);
+ if (!isReflectionMode) {
+ // Switching to reflection mode
+ setProtoFilePath('');
+ dispatch(updateRequestProtoPath({
+ protoPath: '',
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ }));
+ if (url) {
+ handleReflection(url);
+ }
+ } else {
+ // Switching to proto file mode
+ setGrpcMethods([]);
+ setSelectedGrpcMethod(null);
+ onMethodSelect({ path: '', type: '' });
+ }
+ }}
+ size="2xs"
+ />
+
+ Reflection
+
+
+
+
+
+ {!isReflectionMode && (
+ <>
+ {collectionProtoFiles && collectionProtoFiles.length > 0 && (
+
+
+
From Collection Settings
+
{
+ e.stopPropagation();
+ handleOpenCollectionGrpc();
+ }}
+ className="text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
+ >
+
+
+
+
+ {invalidProtoFiles.length > 0 && (
+
+
+
+ Some proto files could not be found. {
+ e.stopPropagation();
+ handleOpenCollectionGrpc();
+ }}
+ className="text-red-600 dark:text-red-400 underline hover:text-red-700 dark:hover:text-red-300 ml-1"
+ >
+ Manage proto files
+
+
+
+ )}
+
+
+ {collectionProtoFilesExistence.map((protoFile, index) => {
+ const isSelected = protoFilePath === protoFile.absolutePath;
+ const isInvalid = !protoFile.exists;
+
+ return (
+
{
+ if (!isInvalid) {
+ setShowProtoDropdown(false);
+ handleSelectCollectionProtoFile(protoFile);
+ }
+ }}
+ >
+
+
+
+
+
+ {getBasename(protoFile.absolutePath)}
+ {isInvalid && (
+
+
+
+ )}
+
+
{protoFile.path}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {collectionProtoFiles && collectionProtoFiles.length > 0 && (
+
+ )}
+
+ {protoFilePath && !collectionProtoFilesExistence.some(pf =>
+ pf.absolutePath === protoFilePath
+ ) && (
+
+
Current Proto File
+ {!currentProtoFileExists && (
+
+
+
+ Selected proto file not found. Please select a valid proto file from collection settings or browse for a new one.
+
+
+ )}
+
+
+
+
+
+
+ {getBasename(protoFilePath)}
+ {!currentProtoFileExists && (
+
+
+
+ )}
+
+
{protoFilePath}
+
+
+
+ {
+ e.stopPropagation();
+ handleResetProtoFile();
+ }}
+ >
+
+
+
+
+
+
+
+ )}
+
+
+ {
+ handleSelectProtoFile(e);
+ }}
+ >
+
+ Browse for Proto File
+
+
+ >
+ )}
+
+ {isReflectionMode && (
+
+
+ Using server reflection to discover gRPC methods.
+
+
+ )}
+
+
+
+
+
{
+ e.stopPropagation();
+ if (isReflectionMode) {
+ handleReflection(url, true);
+ } else if (protoFilePath) {
+ loadMethodsFromProtoFile(protoFilePath, true);
+ } else {
+ toast.error('No proto file selected');
+ }
+ }}
+ >
+
+
+ {isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'}
+
+
+
+
{
+ e.stopPropagation();
+ handleGrpcurl(url);
+ }}
+ >
+
+ Generate grpcurl command
+
+
+
{
+ e.stopPropagation();
+ if (!item.draft) return;
+ onSave();
+ }}
+ >
+
+
+ Save ({saveShortcut})
+
+
+
+ {isConnectionActive && isStreamingMethod && (
+
+
+
+ Cancel
+
+
+ {isClientStreamingMethod &&
+
+
}
+
+ )}
+
+ {(!isConnectionActive || !isStreamingMethod) && (
+
{
+ e.stopPropagation();
+ handleRun(e);
+ }}
+ >
+
+
+ )}
+
+
+ {isConnectionActive && isStreamingMethod && (
+
+ )}
+
+ {showGrpcurlModal && (
+ setShowGrpcurlModal(false)}
+ command={grpcurlCommand}
+ />
+ )}
+
+ );
+};
+
+export default GrpcQueryUrl;
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js
new file mode 100644
index 000000000..3b95b708f
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/GrpcAuthMode/index.js
@@ -0,0 +1,85 @@
+import React, { useRef, forwardRef } from 'react';
+import get from 'lodash/get';
+import { IconCaretDown } from '@tabler/icons';
+import Dropdown from 'components/Dropdown';
+import { useDispatch } from 'react-redux';
+import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
+import { humanizeRequestAuthMode } from 'utils/collections';
+import StyledWrapper from '../../../Auth/AuthMode/StyledWrapper';
+
+const GrpcAuthMode = ({ item, collection }) => {
+ const dispatch = useDispatch();
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+ const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
+
+ const authModes = [
+ {
+ name: 'Basic Auth',
+ mode: 'basic'
+ },
+ {
+ name: 'Bearer Token',
+ mode: 'bearer'
+ },
+ {
+ name: 'API Key',
+ mode: 'apikey'
+ },
+ {
+ name: 'OAuth2',
+ mode: 'oauth2'
+ },
+ {
+ name: 'Inherit',
+ mode: 'inherit'
+ },
+ {
+ name: 'No Auth',
+ mode: 'none'
+ }
+ ];
+
+ const Icon = forwardRef((props, ref) => {
+ return (
+
+ {humanizeRequestAuthMode(authMode)}
+
+ );
+ });
+
+ const onModeChange = (value) => {
+ dispatch(
+ updateRequestAuthMode({
+ itemUid: item.uid,
+ collectionUid: collection.uid,
+ mode: value
+ })
+ );
+ };
+
+ const onClickHandler = (mode) => {
+ dropdownTippyRef?.current?.hide();
+ onModeChange(mode);
+ };
+
+ return (
+
+
+
} placement="bottom-end">
+ {authModes.map((authMode) => (
+
onClickHandler(authMode.mode)}
+ >
+ {authMode.name}
+
+ ))}
+
+
+
+ );
+};
+
+export default GrpcAuthMode;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/StyledWrapper.js
new file mode 100644
index 000000000..f76b0d9a4
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/StyledWrapper.js
@@ -0,0 +1,9 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ .inherit-mode-text {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js
new file mode 100644
index 000000000..b30be89e5
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/GrpcAuth/index.js
@@ -0,0 +1,125 @@
+import React, { useEffect } from 'react';
+import get from 'lodash/get';
+import { useDispatch } from 'react-redux';
+import GrpcAuthMode from './GrpcAuthMode';
+import BearerAuth from '../../Auth/BearerAuth';
+import BasicAuth from '../../Auth/BasicAuth';
+import ApiKeyAuth from '../../Auth/ApiKeyAuth';
+import OAuth2 from '../../Auth/OAuth2/index';
+import StyledWrapper from './StyledWrapper';
+import { humanizeRequestAuthMode } from 'utils/collections';
+import { getTreePathFromCollectionToItem } from 'utils/collections/index';
+import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
+import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+
+// List of auth modes supported by gRPC
+const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
+
+const GrpcAuth = ({ item, collection }) => {
+ const dispatch = useDispatch();
+ const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+
+ const request = item.draft
+ ? get(item, 'draft.request', {})
+ : get(item, 'request', {});
+
+ const save = () => {
+ return saveRequest(item.uid, collection.uid);
+ };
+
+ // Reset to 'none' if current auth mode is not supported by gRPC
+ useEffect(() => {
+ if (authMode && !supportedGrpcAuthModes.includes(authMode)) {
+ dispatch(
+ updateRequestAuthMode({
+ itemUid: item.uid,
+ collectionUid: collection.uid,
+ mode: 'none'
+ })
+ );
+ }
+ }, [authMode, collection.uid, dispatch, item.uid]);
+
+ const getEffectiveAuthSource = () => {
+ if (authMode !== 'inherit') return null;
+
+ const collectionAuth = get(collection, 'root.request.auth');
+ let effectiveSource = {
+ type: 'collection',
+ name: 'Collection',
+ auth: collectionAuth
+ };
+
+ // Check folders in reverse to find the closest auth configuration
+ for (let i of [...requestTreePath].reverse()) {
+ if (i.type === 'folder') {
+ const folderAuth = get(i, 'root.request.auth');
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
+ effectiveSource = {
+ type: 'folder',
+ name: i.name,
+ auth: folderAuth
+ };
+ break;
+ }
+ }
+ }
+
+ return effectiveSource;
+ };
+
+ const getAuthView = () => {
+ switch (authMode) {
+ case 'basic': {
+ return ;
+ }
+ case 'bearer': {
+ return ;
+ }
+ case 'apikey': {
+ return ;
+ }
+ case 'oauth2': {
+ return ;
+ }
+ case 'inherit': {
+ const source = getEffectiveAuthSource();
+
+ // Only show inherited auth if it's one of the supported types
+ if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) {
+ return (
+ <>
+
+
Auth inherited from {source.name}:
+
{humanizeRequestAuthMode(source.auth?.mode)}
+
+ >
+ );
+ } else {
+ return (
+ <>
+
+
Inherited auth not supported by gRPC. Using no auth instead.
+
+ >
+ );
+ }
+ }
+ default: {
+ return null;
+ }
+ }
+ };
+
+ return (
+
+
+
+
+ {getAuthView()}
+
+ );
+};
+
+export default GrpcAuth;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js
new file mode 100644
index 000000000..e6a766672
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/StyledWrapper.js
@@ -0,0 +1,34 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ div.tabs {
+ div.tab {
+ padding: 6px 0px;
+ border: none;
+ border-bottom: solid 2px transparent;
+ margin-right: 1.25rem;
+ color: var(--color-tab-inactive);
+ cursor: pointer;
+
+ &:focus,
+ &:active,
+ &:focus-within,
+ &:focus-visible,
+ &:target {
+ outline: none !important;
+ box-shadow: none !important;
+ }
+
+ &.active {
+ color: ${(props) => props.theme.tabs.active.color} !important;
+ border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
+ }
+
+ .content-indicator {
+ color: ${(props) => props.theme.text}
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js
new file mode 100644
index 000000000..ef961b79d
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/GrpcRequestPane/index.js
@@ -0,0 +1,124 @@
+import React from 'react';
+import classnames from 'classnames';
+import { useSelector, useDispatch } from 'react-redux';
+import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
+import RequestHeaders from 'components/RequestPane/RequestHeaders';
+import GrpcBody from 'components/RequestPane/GrpcBody';
+import GrpcAuth from './GrpcAuth/index';
+import StatusDot from 'components/StatusDot/index';
+import HeightBoundContainer from 'ui/HeightBoundContainer';
+import StyledWrapper from './StyledWrapper';
+import { find, get } from 'lodash';
+import Documentation from 'components/Documentation/index';
+import { useEffect } from 'react';
+import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
+
+const GrpcRequestPane = ({ item, collection, handleRun }) => {
+ const dispatch = useDispatch();
+ const tabs = useSelector((state) => state.tabs.tabs);
+ const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
+
+ const selectTab = (tab) => {
+ dispatch(
+ updateRequestPaneTab({
+ uid: item.uid,
+ requestPaneTab: tab
+ })
+ );
+ };
+
+ const getTabPanel = (tab) => {
+ switch (tab) {
+ case 'body': {
+ return ;
+ }
+ case 'headers': {
+ return ;
+ }
+ case 'auth': {
+ return ;
+ }
+ case 'docs': {
+ return ;
+ }
+ default: {
+ return 404 | Not found
;
+ }
+ }
+ };
+
+ if (!activeTabUid) {
+ return Something went wrong
;
+ }
+
+ const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
+ if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
+ return An error occurred!
;
+ }
+
+ const getTabClassname = (tabName) => {
+ return classnames(`tab select-none ${tabName}`, {
+ active: tabName === focusedTab.requestPaneTab
+ });
+ };
+
+ const isMultipleContentTab = ['script', 'vars', 'auth', 'docs'].includes(focusedTab.requestPaneTab);
+ const body = getPropertyFromDraftOrRequest(item, 'request.body');
+ const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
+ const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
+ const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
+
+ const activeHeadersLength = headers.filter((header) => header.enabled).length;
+ const grpcMessagesCount = body?.grpc?.length || 0;
+
+ // Determine if this is a client streaming request
+ const request = item.draft ? item.draft.request : item.request;
+ const isClientStreaming = request.methodType === 'client-streaming' || request.methodType === 'bidi-streaming';
+
+ useEffect(() => {
+ // Only set the tab to 'body' if no tab is currently set
+ if (!focusedTab?.requestPaneTab) {
+ selectTab('body');
+ }
+ }, []);
+
+ return (
+
+
+
selectTab('body')}>
+ Message
+ {grpcMessagesCount > 0 && (
+ isClientStreaming ? (
+ {grpcMessagesCount}
+ ) : (
+
+ )
+ )}
+
+
selectTab('headers')}>
+ Metadata
+ {activeHeadersLength > 0 && {activeHeadersLength} }
+
+
selectTab('auth')}>
+ Auth
+ {auth.mode !== 'none' && }
+
+
selectTab('docs')}>
+ Docs
+ {docs && docs.length > 0 && }
+
+
+
+
+ {getTabPanel(focusedTab.requestPaneTab)}
+
+
+
+ );
+};
+
+export default GrpcRequestPane;
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
index 09a665e9f..1ca7d39c6 100644
--- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
+++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js
@@ -7,7 +7,6 @@ import RequestHeaders from 'components/RequestPane/RequestHeaders';
import RequestBody from 'components/RequestPane/RequestBody';
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
import Auth from 'components/RequestPane/Auth';
-import DotIcon from 'components/Icons/Dot';
import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
@@ -15,16 +14,12 @@ import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
+import HeightBoundContainer from 'ui/HeightBoundContainer';
+import { useEffect } from 'react';
+import StatusDot from 'components/StatusDot';
+import Settings from 'components/RequestPane/Settings';
-const ContentIndicator = () => {
- return (
-
-
-
- );
-};
-
-const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
+const HttpRequestPane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -67,6 +62,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
case 'docs': {
return ;
}
+ case 'settings': {
+ return ;
+ }
default: {
return 404 | Not found
;
}
@@ -103,6 +101,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
const responseVars = getPropertyFromDraftOrRequest('request.vars.res');
const auth = getPropertyFromDraftOrRequest('request.auth');
+ const tags = getPropertyFromDraftOrRequest('tags');
const activeParamsLength = params.filter((param) => param.enabled).length;
const activeHeadersLength = headers.filter((header) => header.enabled).length;
@@ -111,6 +110,12 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
requestVars.filter((request) => request.enabled).length +
responseVars.filter((response) => response.enabled).length;
+ useEffect(() => {
+ if (activeParamsLength === 0 && body.mode !== 'none') {
+ selectTab('body');
+ }
+ }, []);
+
return (
@@ -120,7 +125,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('body')}>
Body
- {body.mode !== 'none' && }
+ {body.mode !== 'none' && }
selectTab('headers')}>
Headers
@@ -128,7 +133,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('auth')}>
Auth
- {auth.mode !== 'none' && }
+ {auth.mode !== 'none' && }
selectTab('vars')}>
Vars
@@ -136,7 +141,11 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('script')}>
Script
- {(script.req || script.res) && }
+ {(script.req || script.res) && (
+ item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage ?
+ :
+
+ )}
selectTab('assert')}>
Assert
@@ -144,11 +153,19 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
selectTab('tests')}>
Tests
- {tests && tests.length > 0 && }
+ {tests && tests.length > 0 && (
+ item.testScriptErrorMessage ?
+ :
+
+ )}
selectTab('docs')}>
Docs
- {docs && docs.length > 0 && }
+ {docs && docs.length > 0 && }
+
+ selectTab('settings')}>
+ Settings
+ {tags && tags.length > 0 && }
{focusedTab.requestPaneTab === 'body' ? (
@@ -161,7 +178,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
'mt-5': !isMultipleContentTab
})}
>
- {getTabPanel(focusedTab.requestPaneTab)}
+
+ {getTabPanel(focusedTab.requestPaneTab)}
+
);
diff --git a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
index 60bafc8fe..decc7bd1d 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryEditor/index.js
@@ -18,12 +18,7 @@ import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
-let CodeMirror;
-const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
-
-if (!SERVER_RENDERED) {
- CodeMirror = require('codemirror');
-}
+const CodeMirror = require('codemirror');
const md = new MD();
const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/;
@@ -109,7 +104,7 @@ export default class QueryEditor extends React.Component {
this.props.onPrettifyQuery();
}
},
- /* Shift-Ctrl-P is hard coded in Firefox for private browsing so adding an alternative to Pretiffy */
+ /* Shift-Ctrl-P is hard coded in Firefox for private browsing so adding an alternative to Prettify */
'Shift-Ctrl-F': () => {
if (this.props.onPrettifyQuery) {
this.props.onPrettifyQuery();
diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js
index b460c1b4f..9a23f2f9c 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryParams/StyledWrapper.js
@@ -31,7 +31,7 @@ const Wrapper = styled.div`
}
}
- .btn-add-param {
+ .btn-action {
font-size: 0.8125rem;
&:hover span {
text-decoration: underline;
diff --git a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
index 777280eb0..b5c2c69a7 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryParams/index.js
@@ -1,16 +1,17 @@
-import React from 'react';
+import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import InfoTip from 'components/InfoTip';
import { IconTrash } from '@tabler/icons';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
addQueryParam,
updateQueryParam,
deleteQueryParam,
moveQueryParam,
- updatePathParam
+ updatePathParam,
+ setQueryParams
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
@@ -18,6 +19,7 @@ import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable';
+import BulkEditor from '../../BulkEditor';
const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -25,6 +27,8 @@ const QueryParams = ({ item, collection }) => {
const params = item.draft ? get(item, 'draft.request.params') : get(item, 'request.params');
const queryParams = params.filter((param) => param.type === 'query');
const pathParams = params.filter((param) => param.type === 'path');
+
+ const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const handleAddQueryParam = () => {
dispatch(
@@ -113,14 +117,37 @@ const QueryParams = ({ item, collection }) => {
);
};
+ const toggleBulkEditMode = () => {
+ setIsBulkEditMode(!isBulkEditMode);
+ };
+
+ const handleBulkParamsChange = (newParams) => {
+ const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' }));
+ dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType }));
+ };
+
+ if (isBulkEditMode) {
+ return (
+
+
+
+ );
+ }
+
return (
-
+
Query
@@ -171,13 +198,17 @@ const QueryParams = ({ item, collection }) => {
-
- + Add Param
-
+
+
+ + Add Param
+
+
+ Bulk Edit
+
+
Path
-
Path variables are automatically added whenever the
:name
@@ -186,9 +217,7 @@ const QueryParams = ({ item, collection }) => {
https://example.com/v1/users/:id
- `}
- infotipId="path-param-InfoTip"
- />
+
diff --git a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
index 9f3e600d0..3fe47b041 100644
--- a/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
+++ b/packages/bruno-app/src/components/RequestPane/QueryUrl/index.js
@@ -20,6 +20,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const isMac = isMacOS();
const saveShortcut = isMac ? 'Cmd + S' : 'Ctrl + S';
const editorRef = useRef(null);
+ const isGrpc = item.type === 'grpc-request';
const [methodSelectorWidth, setMethodSelectorWidth] = useState(90);
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
@@ -80,9 +81,17 @@ const QueryUrl = ({ item, collection, handleRun }) => {
return (
-
+ {isGrpc ? (
+
+ gRPC
+
+
+ ) : (
+
+ )}
{
{generateCodeItemModalOpen && (
- setGenerateCodeItemModalOpen(false)} />
+ setGenerateCodeItemModalOpen(false)} />
)}
);
diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js
index 29b66d58d..1d33d9b70 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestBody/RequestBodyMode/index.js
@@ -21,7 +21,7 @@ const RequestBodyMode = ({ item, collection }) => {
const Icon = forwardRef((props, ref) => {
return (
- {humanizeRequestBodyMode(bodyMode)}
+ {humanizeRequestBodyMode(bodyMode)}
);
});
@@ -71,76 +71,85 @@ const RequestBodyMode = ({ item, collection }) => {
} placement="bottom-end">
-
Form
-
{
- dropdownTippyRef.current.hide();
- onModeChange('multipartForm');
- }}
- >
- Multipart Form
-
-
{
- dropdownTippyRef.current.hide();
- onModeChange('formUrlEncoded');
- }}
- >
- Form URL Encoded
-
-
Raw
-
{
- dropdownTippyRef.current.hide();
- onModeChange('json');
- }}
- >
- JSON
-
-
{
- dropdownTippyRef.current.hide();
- onModeChange('xml');
- }}
- >
- XML
-
-
{
- dropdownTippyRef.current.hide();
- onModeChange('text');
- }}
- >
- TEXT
-
-
{
- dropdownTippyRef.current.hide();
- onModeChange('sparql');
- }}
- >
- SPARQL
-
-
Other
-
{
- dropdownTippyRef.current.hide();
- onModeChange('none');
- }}
- >
- No Body
-
+
Form
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('multipartForm');
+ }}
+ >
+ Multipart Form
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('formUrlEncoded');
+ }}
+ >
+ Form URL Encoded
+
+
Raw
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('json');
+ }}
+ >
+ JSON
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('xml');
+ }}
+ >
+ XML
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('text');
+ }}
+ >
+ TEXT
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('sparql');
+ }}
+ >
+ SPARQL
+
+
Other
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('file');
+ }}
+ >
+ File / Binary
+
+
{
+ dropdownTippyRef.current.hide();
+ onModeChange('none');
+ }}
+ >
+ No Body
+
{(bodyMode === 'json' || bodyMode === 'xml') && (
-
+
Prettify
)}
diff --git a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
index ca60c8662..fdc674ae6 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestBody/index.js
@@ -8,6 +8,7 @@ import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
+import FileBody from '../FileBody/index';
const RequestBody = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -48,7 +49,7 @@ const RequestBody = ({ item, collection }) => {
{
onRun={onRun}
onSave={onSave}
mode={codeMirrorMode[bodyMode]}
+ enableVariableHighlighting={true}
+ showHintsFor={['variables']}
/>
);
}
+ if (bodyMode === 'file') {
+ return ;
+ }
+
if (bodyMode === 'formUrlEncoded') {
return ;
}
@@ -72,4 +79,4 @@ const RequestBody = ({ item, collection }) => {
return No Body ;
};
-export default RequestBody;
+export default RequestBody;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js
index 5b787e8bb..86cb4e365 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/StyledWrapper.js
@@ -22,8 +22,11 @@ const Wrapper = styled.div`
}
}
- .btn-add-header {
+ .btn-action {
font-size: 0.8125rem;
+ &:hover span {
+ text-decoration: underline;
+ }
}
input[type='text'] {
diff --git a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js
index d88318017..930a056f9 100644
--- a/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js
+++ b/packages/bruno-app/src/components/RequestPane/RequestHeaders/index.js
@@ -1,10 +1,10 @@
-import React from 'react';
+import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
-import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader } from 'providers/ReduxStore/slices/collections';
+import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
@@ -12,12 +12,16 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable/index';
+import BulkEditor from '../../BulkEditor';
+
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
-const RequestHeaders = ({ item, collection }) => {
+const RequestHeaders = ({ item, collection, addHeaderText }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
+
+ const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const addHeader = () => {
dispatch(
@@ -75,6 +79,28 @@ const RequestHeaders = ({ item, collection }) => {
);
};
+ const toggleBulkEditMode = () => {
+ setIsBulkEditMode(!isBulkEditMode);
+ };
+
+ const handleBulkHeadersChange = (newHeaders) => {
+ dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders }));
+ };
+
+ if (isBulkEditMode) {
+ return (
+
+
+
+ );
+ }
+
return (
-
- + Add Header
-
+
+
+ + {addHeaderText || 'Add Header'}
+
+
+ Bulk Edit
+
+
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/Script/index.js b/packages/bruno-app/src/components/RequestPane/Script/index.js
index ec4f4df95..adcf3ebe6 100644
--- a/packages/bruno-app/src/components/RequestPane/Script/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Script/index.js
@@ -52,6 +52,7 @@ const Script = ({ item, collection }) => {
mode="javascript"
onRun={onRun}
onSave={onSave}
+ showHintsFor={['req', 'bru']}
/>
@@ -66,6 +67,7 @@ const Script = ({ item, collection }) => {
mode="javascript"
onRun={onRun}
onSave={onSave}
+ showHintsFor={['req', 'res', 'bru']}
/>
diff --git a/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js
new file mode 100644
index 000000000..006a9894f
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Settings/Tags/index.js
@@ -0,0 +1,63 @@
+import React, { useCallback, useEffect } from 'react';
+import get from 'lodash/get';
+import { useDispatch } from 'react-redux';
+import { addRequestTag, deleteRequestTag, updateCollectionTagsList } from 'providers/ReduxStore/slices/collections';
+import TagList from 'components/TagList/index';
+import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+
+const Tags = ({ item, collection }) => {
+ const dispatch = useDispatch();
+ // all tags in the collection
+ const collectionTags = collection.allTags || [];
+
+ // tags for the current request
+ const tags = item.draft ? get(item, 'draft.tags', []) : get(item, 'tags', []);
+
+ // Filter out tags that are already associated with the current request
+ const collectionTagsWithoutCurrentRequestTags = collectionTags?.filter(tag => !tags.includes(tag)) || [];
+
+ const handleAdd = useCallback((tag) => {
+ const trimmedTag = tag.trim();
+ if (trimmedTag && !tags.includes(trimmedTag)) {
+ dispatch(
+ addRequestTag({
+ tag: trimmedTag,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ }
+ }, [dispatch, tags, item.uid, collection.uid]);
+
+ const handleRemove = useCallback((tag) => {
+ dispatch(
+ deleteRequestTag({
+ tag,
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+ }, [dispatch, item.uid, collection.uid]);
+
+ const handleRequestSave = () => {
+ dispatch(saveRequest(item.uid, collection.uid));
+ }
+
+ useEffect(() => {
+ dispatch(updateCollectionTagsList({ collectionUid: collection.uid }));
+ }, [collection.uid, dispatch]);
+
+ return (
+
+
+
+ );
+};
+
+export default Tags;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js b/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js
new file mode 100644
index 000000000..f0294aee9
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Settings/ToggleSelector/index.js
@@ -0,0 +1,74 @@
+import React from 'react';
+
+const ToggleSelector = ({
+ checked,
+ onChange,
+ label,
+ description,
+ disabled = false,
+ size = 'small' // 'small', 'medium', 'large'
+}) => {
+ const sizeClasses = {
+ small: {
+ container: 'h-4 w-8',
+ thumb: 'h-3 w-3',
+ translate: checked ? 'translate-x-4' : 'translate-x-1'
+ },
+ medium: {
+ container: 'h-5 w-9',
+ thumb: 'h-3 w-3',
+ translate: checked ? 'translate-x-5' : 'translate-x-1'
+ },
+ large: {
+ container: 'h-6 w-11',
+ thumb: 'h-4 w-4',
+ translate: checked ? 'translate-x-6' : 'translate-x-1'
+ }
+ };
+
+ const currentSize = sizeClasses[size];
+
+ return (
+
+
+
+
+
+
+ {label}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+ );
+};
+
+export default ToggleSelector;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Settings/index.js b/packages/bruno-app/src/components/RequestPane/Settings/index.js
new file mode 100644
index 000000000..df570085d
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestPane/Settings/index.js
@@ -0,0 +1,50 @@
+import React, { useCallback } from 'react';
+import { useDispatch } from 'react-redux';
+import get from 'lodash/get';
+import { IconTag } from '@tabler/icons';
+import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector';
+import { updateItemSettings } from 'providers/ReduxStore/slices/collections';
+import Tags from './Tags/index';
+
+const Settings = ({ item, collection }) => {
+ const dispatch = useDispatch();
+
+ // get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script
+ const getPropertyFromDraftOrRequest = (propertyKey) =>
+ item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {});
+
+ const { encodeUrl } = getPropertyFromDraftOrRequest('settings');
+
+ const onToggleUrlEncoding = useCallback(() => {
+ dispatch(updateItemSettings({
+ collectionUid: collection.uid,
+ itemUid: item.uid,
+ settings: { encodeUrl: !encodeUrl }
+ }));
+ }, [encodeUrl, dispatch, collection.uid, item.uid]);
+
+ return (
+
+ );
+};
+
+export default Settings;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestPane/Tests/index.js b/packages/bruno-app/src/components/RequestPane/Tests/index.js
index c781d34d5..b9c9633be 100644
--- a/packages/bruno-app/src/components/RequestPane/Tests/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Tests/index.js
@@ -5,7 +5,6 @@ import CodeEditor from 'components/CodeEditor';
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
-import StyledWrapper from './StyledWrapper';
const Tests = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -28,19 +27,18 @@ const Tests = ({ item, collection }) => {
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
return (
-
-
-
+
);
};
diff --git a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js
index c073135d3..cd3f83797 100644
--- a/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js
+++ b/packages/bruno-app/src/components/RequestPane/Vars/VarsTable/index.js
@@ -98,7 +98,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
) : (
Expr
-
+
), accessor: 'value', width: '46%' },
{ name: '', accessor: '', width: '14%' }
diff --git a/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js b/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js
new file mode 100644
index 000000000..eff188890
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabPanel/FolderNotFound/index.js
@@ -0,0 +1,42 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import { useDispatch } from 'react-redux';
+
+const FolderNotFound = ({ folderUid }) => {
+ const dispatch = useDispatch();
+ const [showErrorMessage, setShowErrorMessage] = useState(false);
+
+ const closeTab = useCallback(() => {
+ dispatch(
+ closeTabs({
+ tabUids: [folderUid]
+ })
+ );
+ }, [dispatch, folderUid]);
+
+ useEffect(() => {
+ setTimeout(() => {
+ setShowErrorMessage(true);
+ }, 300);
+ }, []);
+
+ if (!showErrorMessage) {
+ return null;
+ }
+
+ return (
+
+
+
Folder no longer exists.
+
+ This can happen when the folder was renamed or deleted on your filesystem.
+
+
+
+ Close Tab
+
+
+ );
+};
+
+export default FolderNotFound;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/StyledWrapper.js
new file mode 100644
index 000000000..ff6c48575
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/StyledWrapper.js
@@ -0,0 +1,19 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ div.card {
+ background: ${(props) => props.theme.requestTabPanel.card.bg};
+ border: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
+
+ div.hr {
+ border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr};
+ height: 1px;
+ }
+
+ div.border-top {
+ border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js
new file mode 100644
index 000000000..9d2ff1346
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabPanel/RequestIsLoading/index.js
@@ -0,0 +1,47 @@
+import { IconLoader2, IconFile } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+const RequestIsLoading = ({ item }) => {
+ return
+
+
+
+
+
+ File Info
+
+
+
+
+
Name:
+
+ {item?.name}
+
+
+
+
+
Path:
+
+ {item?.pathname}
+
+
+
+
+
Size:
+
+ {item?.size?.toFixed?.(2)} MB
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+}
+
+export default RequestIsLoading;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/StyledWrapper.js
new file mode 100644
index 000000000..ff6c48575
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/StyledWrapper.js
@@ -0,0 +1,19 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ div.card {
+ background: ${(props) => props.theme.requestTabPanel.card.bg};
+ border: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
+
+ div.hr {
+ border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.card.hr};
+ height: 1px;
+ }
+
+ div.border-top {
+ border-top: 1px solid ${(props) => props.theme.requestTabPanel.card.border};
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js
new file mode 100644
index 000000000..08d011326
--- /dev/null
+++ b/packages/bruno-app/src/components/RequestTabPanel/RequestNotLoaded/index.js
@@ -0,0 +1,71 @@
+import { IconLoader2, IconFile, IconAlertTriangle } from '@tabler/icons';
+import { loadLargeRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { useDispatch } from 'react-redux';
+import StyledWrapper from './StyledWrapper';
+
+const RequestNotLoaded = ({ collection, item }) => {
+ const dispatch = useDispatch();
+
+ const handleLoadLargeRequest = () => {
+ !item?.loading && dispatch(loadLargeRequest({ collectionUid: collection?.uid, pathname: item?.pathname }));
+ }
+
+ return
+
+
+
+
+
+ File Info
+
+
+
+
+
+
+
Path:
+
{item?.pathname}
+
+
+
+
Size:
+
{item?.size?.toFixed?.(2)} MB
+
+
+ {!item?.error && (
+
+
+
+ The request wasn't loaded due to its large size. Please try again with the following options:
+
+
+
+ Load Request
+
+
(Uses a regex based parsing approach)
+
+
+ )}
+
+ {item?.loading && (
+ <>
+
+
+
+ Loading...
+
+ >
+ )}
+
+
+
+
+}
+
+export default RequestNotLoaded;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js
index ec0a03217..5349f2410 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/StyledWrapper.js
@@ -3,9 +3,13 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
&.dragging {
cursor: col-resize;
+
+ &.vertical-layout {
+ cursor: row-resize;
+ }
}
- div.drag-request {
+ div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
@@ -14,19 +18,50 @@ const StyledWrapper = styled.div`
padding: 0;
cursor: col-resize;
background: transparent;
+ position: relative;
- div.drag-request-border {
+ div.dragbar-handle {
display: flex;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
- &:hover div.drag-request-border {
+ &:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
+ &.vertical-layout {
+ .request-pane {
+ padding-bottom: 0.5rem;
+ }
+
+ .response-pane {
+ padding-top: 0.5rem;
+ }
+
+ div.dragbar-wrapper {
+ width: 100%;
+ height: 10px;
+ cursor: row-resize;
+ padding: 0 1rem;
+ position: relative;
+
+ div.dragbar-handle {
+ width: 100%;
+ height: 1px;
+ border-left: none;
+ border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
+ }
+
+ &:hover div.dragbar-handle {
+ border-left: none;
+ border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
+ }
+ }
+ }
+
div.graphql-docs-explorer-container {
background: white;
outline: none;
diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js
index 4bcfff1c3..72cd76701 100644
--- a/packages/bruno-app/src/components/RequestTabPanel/index.js
+++ b/packages/bruno-app/src/components/RequestTabPanel/index.js
@@ -4,13 +4,16 @@ 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 Welcome from 'components/Welcome';
import { findItemInCollection } from 'utils/collections';
import { updateRequestPaneTabWidth } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import RequestNotFound from './RequestNotFound';
-import QueryUrl from 'components/RequestPane/QueryUrl';
+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';
@@ -22,10 +25,15 @@ import SecuritySettings from 'components/SecuritySettings';
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';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
-const DEFAULT_PADDING = 5;
+const MIN_TOP_PANE_HEIGHT = 150;
+const MIN_BOTTOM_PANE_HEIGHT = 150;
const RequestTabPanel = () => {
if (typeof window == 'undefined') {
@@ -37,6 +45,8 @@ const RequestTabPanel = () => {
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 isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
// merge `globalEnvironmentVariables` into the active collection and rebuild `collections` immer proxy object
let collections = produce(_collections, (draft) => {
@@ -60,13 +70,15 @@ const RequestTabPanel = () => {
let asideWidth = useSelector((state) => state.app.leftSidebarWidth);
const [leftPaneWidth, setLeftPaneWidth] = useState(
focusedTab && focusedTab.requestPaneWidth ? focusedTab.requestPaneWidth : (screenWidth - asideWidth) / 2.2
- ); // 2.2 so that request pane is relatively smaller
- const [rightPaneWidth, setRightPaneWidth] = useState(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
+ ); // 2.2 is intentional to make both panes appear to be of equal width
+ const [topPaneHeight, setTopPaneHeight] = useState(focusedTab?.requestPaneHeight || MIN_TOP_PANE_HEIGHT);
const [dragging, setDragging] = useState(false);
+ const dragOffset = useRef({ x: 0, y: 0 });
// 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);
const [showGqlDocs, setShowGqlDocs] = useState(false);
const onSchemaLoad = (schema) => setSchema(schema);
@@ -81,43 +93,72 @@ const RequestTabPanel = () => {
};
useEffect(() => {
- const leftPaneWidth = (screenWidth - asideWidth) / 2.2;
- setLeftPaneWidth(leftPaneWidth);
- }, [screenWidth]);
-
- useEffect(() => {
- setRightPaneWidth(screenWidth - asideWidth - leftPaneWidth - DEFAULT_PADDING);
- }, [screenWidth, asideWidth, leftPaneWidth]);
+ // Initialize vertical heights when switching to vertical layout
+ if (mainSectionRef.current) {
+ const mainRect = mainSectionRef.current.getBoundingClientRect();
+ if (isVerticalLayout) {
+ const initialHeight = mainRect.height / 2;
+ setTopPaneHeight(initialHeight);
+ // In vertical mode, set leftPaneWidth to full container width
+ setLeftPaneWidth(mainRect.width);
+ } else {
+ // In horizontal mode, set to roughly half width
+ setLeftPaneWidth((screenWidth - asideWidth) / 2.2);
+ }
+ }
+ }, [isVerticalLayout, screenWidth, asideWidth]);
const handleMouseMove = (e) => {
- if (dragging) {
+ if (dragging && mainSectionRef.current) {
e.preventDefault();
- let leftPaneXPosition = e.clientX + 2;
- if (
- leftPaneXPosition < asideWidth + DEFAULT_PADDING + MIN_LEFT_PANE_WIDTH ||
- leftPaneXPosition > screenWidth - MIN_RIGHT_PANE_WIDTH
- ) {
- return;
+ const mainRect = mainSectionRef.current.getBoundingClientRect();
+
+ if (isVerticalLayout) {
+ const newHeight = e.clientY - mainRect.top - dragOffset.current.y;
+ if (newHeight < MIN_TOP_PANE_HEIGHT || newHeight > mainRect.height - MIN_BOTTOM_PANE_HEIGHT) {
+ return;
+ }
+
+ setTopPaneHeight(newHeight);
+ } else {
+ const newWidth = e.clientX - mainRect.left - dragOffset.current.x;
+ if (newWidth < MIN_LEFT_PANE_WIDTH || newWidth > mainRect.width - MIN_RIGHT_PANE_WIDTH) {
+ return;
+ }
+ setLeftPaneWidth(newWidth);
}
- setLeftPaneWidth(leftPaneXPosition - asideWidth);
- setRightPaneWidth(screenWidth - e.clientX - DEFAULT_PADDING);
}
};
+
const handleMouseUp = (e) => {
- if (dragging) {
+ if (dragging && mainSectionRef.current) {
e.preventDefault();
setDragging(false);
- dispatch(
- updateRequestPaneTabWidth({
- uid: activeTabUid,
- requestPaneWidth: e.clientX - asideWidth - DEFAULT_PADDING
- })
- );
+ if (!isVerticalLayout) {
+ const mainRect = mainSectionRef.current.getBoundingClientRect();
+ dispatch(
+ updateRequestPaneTabWidth({
+ uid: activeTabUid,
+ requestPaneWidth: e.clientX - mainRect.left
+ })
+ );
+ }
}
};
+
const handleDragbarMouseDown = (e) => {
e.preventDefault();
setDragging(true);
+
+ if (isVerticalLayout) {
+ const dragBar = e.currentTarget;
+ const dragBarRect = dragBar.getBoundingClientRect();
+ dragOffset.current.y = e.clientY - dragBarRect.top;
+ } else {
+ const dragBar = e.currentTarget;
+ const dragBarRect = dragBar.getBoundingClientRect();
+ dragOffset.current.x = e.clientX - dragBarRect.left;
+ }
};
useEffect(() => {
@@ -128,7 +169,7 @@ const RequestTabPanel = () => {
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
};
- }, [dragging, asideWidth]);
+ }, [dragging]);
if (!activeTabUid) {
return ;
@@ -142,6 +183,9 @@ const RequestTabPanel = () => {
return Collection not found!
;
}
+ const item = findItemInCollection(collection, activeTabUid);
+ const isGrpcRequest = item?.type === 'grpc-request';
+
if (focusedTab.type === 'collection-runner') {
return ;
}
@@ -153,8 +197,17 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'collection-settings') {
return ;
}
+
+ if (focusedTab.type === 'collection-overview') {
+ return ;
+ }
+
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
+ if (!folder) {
+ return ;
+ }
+
return ;
}
@@ -162,12 +215,32 @@ const RequestTabPanel = () => {
return ;
}
- const item = findItemInCollection(collection, activeTabUid);
if (!item || !item.uid) {
return ;
}
+ if (item?.partial) {
+ return ;
+ }
+
+ if (item?.loading) {
+ return ;
+ }
+
const handleRun = async () => {
+ const isGrpcRequest = item?.type === 'grpc-request';
+ 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;
+ }
+
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => toast.dismiss(t.id)} />, {
duration: 5000
@@ -176,15 +249,23 @@ const RequestTabPanel = () => {
};
return (
-
+
-
+ {isGrpcRequest ? (
+
+ ) : (
+
+ )}
-
+
@@ -192,7 +273,6 @@ const RequestTabPanel = () => {
{
) : null}
{item.type === 'http-request' ? (
-
+
+ ) : null}
+
+ {isGrpcRequest ? (
+
) : null}
-
-
+
-
-
+
+ {item.type === 'grpc-request' ? (
+
+ ) : (
+
+ )}
diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js
index 8ca76b15e..447523fdb 100644
--- a/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/CollectionToolBar/index.js
@@ -35,7 +35,7 @@ const CollectionToolBar = ({ collection }) => {
const viewCollectionSettings = () => {
dispatch(
addTab({
- uid: uuid(),
+ uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js
index c5d09faa8..b895c10fe 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js
@@ -2,10 +2,18 @@ import React from 'react';
import CloseTabIcon from './CloseTabIcon';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock } from '@tabler/icons';
-const SpecialTab = ({ handleCloseClick, type, tabName }) => {
+const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick }) => {
const getTabInfo = (type, tabName) => {
switch (type) {
case 'collection-settings': {
+ return (
+
+
+ Collection
+
+ );
+ }
+ case 'collection-overview': {
return (
<>
@@ -23,7 +31,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName }) => {
}
case 'folder-settings': {
return (
-
+
{tabName || 'Folder'}
diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
index e73313c13..fe574fdaa 100644
--- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js
@@ -1,6 +1,6 @@
import React, { useState, useRef, Fragment } from 'react';
import get from 'lodash/get';
-import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { useTheme } from 'providers/Theme';
@@ -22,6 +22,7 @@ import { flattenItems } from 'utils/collections/index';
const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUid }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
+ const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
const [showConfirmClose, setShowConfirmClose] = useState(false);
const dropdownTippyRef = useRef();
@@ -65,21 +66,23 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
const getMethodColor = (method = '') => {
- const theme = storedTheme === 'dark' ? darkTheme : lightTheme;
return theme.request.methods[method.toLocaleLowerCase()];
};
+
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
- if (['collection-settings', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
+ if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'security-settings'].includes(tab.type)) {
return (
- {tab.type === 'folder-settings' ? (
-
+ {tab.type === 'folder-settings' && !folder ? (
+
+ ) : tab.type === 'folder-settings' ? (
+ dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
) : (
-
+ dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}
);
@@ -105,6 +108,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
);
}
+ const isGrpc = item.type === 'grpc-request';
const method = item.draft ? get(item, 'draft.request.method') : get(item, 'request.method');
return (
@@ -144,8 +148,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/>
)}
dispatch(makeTabPermanent({ uid: tab.uid }))}
onMouseUp={(e) => {
if (!item.draft) return handleMouseUp(e);
@@ -156,8 +161,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
}}
>
-
- {method}
+
+ {isGrpc ? 'gRPC' : method}
{item.name}
@@ -260,13 +265,13 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
return (
{showAddNewRequestModal && (
- setShowAddNewRequestModal(false)} />
+ setShowAddNewRequestModal(false)} />
)}
{showCloneRequestModal && (
setShowCloneRequestModal(false)}
/>
)}
diff --git a/packages/bruno-app/src/components/RequestTabs/index.js b/packages/bruno-app/src/components/RequestTabs/index.js
index d0cd0b459..fcba790a6 100644
--- a/packages/bruno-app/src/components/RequestTabs/index.js
+++ b/packages/bruno-app/src/components/RequestTabs/index.js
@@ -18,6 +18,7 @@ const RequestTabs = () => {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const collections = useSelector((state) => state.collections.collections);
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
+ const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
const getTabClassname = (tab, index) => {
@@ -49,7 +50,8 @@ const RequestTabs = () => {
const activeCollection = find(collections, (c) => c.uid === activeTab.collectionUid);
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab.collectionUid);
- const maxTablistWidth = screenWidth - leftSidebarWidth - 150;
+ const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
+ const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
const tabsWidth = collectionRequestTabs.length * 150 + 34; // 34: (+)icon
const showChevrons = maxTablistWidth < tabsWidth;
@@ -79,7 +81,7 @@ const RequestTabs = () => {
return (
{newRequestModalOpen && (
- setNewRequestModalOpen(false)} />
+ setNewRequestModalOpen(false)} />
)}
{collectionRequestTabs && collectionRequestTabs.length ? (
<>
diff --git a/packages/bruno-app/src/components/RequestPane/Tests/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/StyledWrapper.js
similarity index 54%
rename from packages/bruno-app/src/components/RequestPane/Tests/StyledWrapper.js
rename to packages/bruno-app/src/components/ResponsePane/ClearTimeline/StyledWrapper.js
index 9f7583222..8c32a8bab 100644
--- a/packages/bruno-app/src/components/RequestPane/Tests/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/StyledWrapper.js
@@ -1,10 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
- div.CodeMirror {
- /* todo: find a better way */
- height: calc(100vh - 220px);
- }
+ font-size: 0.8125rem;
+ color: ${(props) => props.theme.requestTabPanel.responseStatus};
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js
new file mode 100644
index 000000000..18704cc41
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ClearTimeline/index.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { useDispatch } from 'react-redux';
+import StyledWrapper from './StyledWrapper';
+import { clearRequestTimeline } from 'providers/ReduxStore/slices/collections/index';
+
+const ClearTimeline = ({ collection, item }) => {
+ const dispatch = useDispatch();
+
+ const clearResponse = () =>
+ dispatch(
+ clearRequestTimeline({
+ itemUid: item.uid,
+ collectionUid: collection.uid
+ })
+ );
+
+ return (
+
+
+ Clear Timeline
+
+
+ );
+};
+
+export default ClearTimeline;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/StyledWrapper.js
new file mode 100644
index 000000000..f302b86dd
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/StyledWrapper.js
@@ -0,0 +1,44 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ border-left: 4px solid ${(props) => props.theme.colors.text.danger};
+ border-top: 1px solid transparent;
+ border-right: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ border-radius: 0.375rem;
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ max-height: 200px;
+ min-height: 70px;
+ overflow-y: auto;
+ background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)')};
+
+ .close-button {
+ opacity: 0.7;
+ transition: opacity 0.2s;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ svg {
+ color: ${(props) => props.theme.text};
+ }
+ }
+
+ .error-title {
+ font-weight: 600;
+ margin-bottom: 0.375rem;
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+
+ .error-message {
+ font-family: monospace;
+ font-size: 0.6875rem;
+ line-height: 1.25rem;
+ white-space: pre-wrap;
+ word-break: break-all;
+ color: ${(props) => props.theme.text};
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/index.js
new file mode 100644
index 000000000..d2376401d
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcError/index.js
@@ -0,0 +1,23 @@
+import React from 'react';
+import { IconX } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+const GrpcError = ({ error, onClose }) => {
+ if (!error) return null;
+
+ return (
+
+
+
+
gRPC Server Error
+
{typeof error === 'string' ? error : JSON.stringify(error, null, 2)}
+
+
+
+
+
+
+ );
+};
+
+export default GrpcError;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/StyledWrapper.js
new file mode 100644
index 000000000..81b4c33b1
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/StyledWrapper.js
@@ -0,0 +1,96 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ height: 100%;
+ overflow: hidden;
+ background: ${(props) => props.theme.bg};
+ border-radius: 4px;
+
+ .CodeMirror {
+ height: 100%;
+ font-family: ${(props) => (props.font === 'default' ? 'monospace' : props.font)};
+ font-size: ${(props) => (props.fontSize ? props.fontSize : '13px')};
+ }
+
+ .accordion-header {
+ background-color: ${(props) => props.theme.requestTabPanel.card.bg};
+
+ &:hover {
+ background-color: ${(props) => props.theme.plainGrid.hoverBg};
+ }
+
+ &.open {
+ background-color: ${(props) => props.theme.plainGrid.hoverBg};
+ }
+ }
+
+ .error-header {
+ background-color: ${(props) => (props.theme.bg === '#1e1e1e' ? 'rgba(185, 28, 28, 0.1)' : '#fee2e2')};
+ }
+
+ .error-text {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+
+ div.tabs {
+ div.tab {
+ padding: 6px 0px;
+ border: none;
+ border-bottom: solid 2px transparent;
+ margin-right: 1.25rem;
+ color: var(--color-tab-inactive);
+ cursor: pointer;
+
+ &:focus,
+ &:active,
+ &:focus-within,
+ &:focus-visible,
+ &:target {
+ outline: none !important;
+ box-shadow: none !important;
+ }
+
+ &.active {
+ color: ${(props) => props.theme.tabs.active.color} !important;
+ border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
+ }
+ }
+ }
+
+ .stream-status {
+ display: inline-flex;
+ align-items: center;
+
+ &.complete {
+ color: ${(props) => props.theme.colors.text.green};
+ }
+
+ &.cancelled {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+
+ &.streaming {
+ color: ${(props) => props.theme.colors.text.blue};
+ }
+ }
+
+ .message-counter {
+ display: inline-flex;
+ align-items: center;
+ margin-left: 10px;
+ }
+
+ .response-list {
+ max-height: 500px;
+ overflow-y: auto;
+ }
+
+ .response-message {
+ margin-bottom: 8px;
+ padding: 8px;
+ border-radius: 4px;
+ background-color: var(--color-panel-background);
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/index.js
new file mode 100644
index 000000000..c6ceeff61
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcQueryResult/index.js
@@ -0,0 +1,126 @@
+import React, { useState, useEffect } from 'react';
+import Accordion from 'components/Accordion';
+import CodeEditor from 'components/CodeEditor';
+import { get } from 'lodash';
+import { useSelector } from 'react-redux';
+import { useTheme } from 'providers/Theme/index';
+import StyledWrapper from './StyledWrapper';
+import { formatISO9075 } from 'date-fns';
+import GrpcError from '../GrpcError';
+
+const GrpcQueryResult = ({ item, collection }) => {
+ const { displayedTheme } = useTheme();
+ const preferences = useSelector((state) => state.app.preferences);
+ const [showErrorMessage, setShowErrorMessage] = useState(true);
+
+ const response = item.response || {};
+ const responsesList = response?.responses || [];
+ // Reverse the responses list to show the most recent at the top
+ const reversedResponsesList = [...responsesList].reverse();
+ const hasError = response.isError;
+ const hasResponses = responsesList.length > 0;
+ const errorMessage = response.error;
+
+ // Reset error visibility when a new response is received
+ useEffect(() => {
+ if (hasError) {
+ setShowErrorMessage(true);
+ }
+ }, [response, hasError]);
+
+ // Format a timestamp to a human-readable format
+ const formatTimestamp = (timestamp) => {
+ if (!timestamp) return 'Unknown time';
+
+ try {
+ const date = new Date(timestamp);
+ return formatISO9075(date);
+ } catch (e) {
+ return 'Invalid time';
+ }
+ };
+
+ // Format JSON for display
+ const formatJSON = (data) => {
+ try {
+ if (typeof data === 'string') {
+ return JSON.stringify(JSON.parse(data), null, 2);
+ }
+ return JSON.stringify(data, null, 2);
+ } catch (e) {
+ return typeof data === 'string' ? data : JSON.stringify(data);
+ }
+ };
+
+ if (!hasResponses && !hasError) {
+ return (
+
+ No messages received
+
+ );
+ }
+
+ return (
+
+ {hasError && showErrorMessage && setShowErrorMessage(false)} />}
+ {hasResponses && (
+
+ {responsesList.length === 1 ? (
+ // Single message - render directly without accordion
+
+
+
+ ) : (
+ // Multiple messages - use accordion
+
+ {reversedResponsesList.map((response, index) => {
+ // Calculate the original response number (for display purposes)
+ const originalIndex = responsesList.length - index - 1;
+
+ return (
+
+
+
+
+ Response {originalIndex + 1} {index === 0 ? '(Latest)' : ''}
+
+
+
+
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+ )}
+ {hasError && !hasResponses && !showErrorMessage && (
+
+ No messages received. A server error occurred but has been dismissed.
+
+ )}
+
+ );
+};
+
+export default GrpcQueryResult;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/StyledWrapper.js
new file mode 100644
index 000000000..d42e77f7f
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/StyledWrapper.js
@@ -0,0 +1,31 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ table {
+ width: 100%;
+ border-collapse: collapse;
+
+ thead {
+ color: #777777;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ }
+
+ td {
+ padding: 6px 10px;
+
+ &.value {
+ word-break: break-all;
+ }
+ }
+
+ tbody {
+ tr:nth-child(odd) {
+ background-color: ${(props) => props.theme.table.striped};
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/index.js
new file mode 100644
index 000000000..a7390558d
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcResponseHeaders/index.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import StyledWrapper from './StyledWrapper';
+
+const GrpcResponseHeaders = ({ metadata }) => {
+ // Ensure headers is an array
+ const metadataArray = Array.isArray(metadata) ? metadata : [];
+
+ return (
+
+
+
+
+ Name
+ Value
+
+
+
+ {metadataArray && metadataArray.length ? (
+ metadataArray.map((metadata, index) => (
+
+ {metadata.name}
+ {metadata.value}
+
+ ))
+ ) : (
+
+
+ No metadata received
+
+
+ )}
+
+
+
+ );
+};
+
+export default GrpcResponseHeaders;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/StyledWrapper.js
new file mode 100644
index 000000000..bed367559
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/StyledWrapper.js
@@ -0,0 +1,22 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ font-size: 0.75rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+
+ &.text-ok {
+ color: ${(props) => props.theme.requestTabPanel.responseOk};
+ }
+
+ &.text-pending {
+ color: ${(props) => props.theme.requestTabPanel.responsePending};
+ }
+
+ &.text-error {
+ color: ${(props) => props.theme.requestTabPanel.responseError};
+ }
+`;
+
+export default Wrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/get-grpc-status-code-phrase.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/get-grpc-status-code-phrase.js
new file mode 100644
index 000000000..a8c96e645
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/get-grpc-status-code-phrase.js
@@ -0,0 +1,22 @@
+// https://grpc.github.io/grpc/core/md_doc_statuscodes.html
+const grpcStatusCodePhraseMap = {
+ 0: 'OK',
+ 1: 'Cancelled',
+ 2: 'Unknown',
+ 3: 'Invalid Argument',
+ 4: 'Deadline Exceeded',
+ 5: 'Not Found',
+ 6: 'Already Exists',
+ 7: 'Permission Denied',
+ 8: 'Resource Exhausted',
+ 9: 'Failed Precondition',
+ 10: 'Aborted',
+ 11: 'Out of Range',
+ 12: 'Unimplemented',
+ 13: 'Internal',
+ 14: 'Unavailable',
+ 15: 'Data Loss',
+ 16: 'Unauthenticated'
+};
+
+export default grpcStatusCodePhraseMap;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/index.js
new file mode 100644
index 000000000..af4f10db6
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/GrpcStatusCode/index.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import classnames from 'classnames';
+import grpcStatusCodePhraseMap from './get-grpc-status-code-phrase';
+import StyledWrapper from './StyledWrapper';
+
+const GrpcStatusCode = ({ 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
+ });
+ };
+
+ const statusText = text || grpcStatusCodePhraseMap[status]
+
+ return (
+
+ {Number.isInteger(status) ? {status}
: null}
+ {statusText && {statusText}
}
+
+ );
+};
+
+export default GrpcStatusCode;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/StyledWrapper.js
new file mode 100644
index 000000000..d42e77f7f
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/StyledWrapper.js
@@ -0,0 +1,31 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ table {
+ width: 100%;
+ border-collapse: collapse;
+
+ thead {
+ color: #777777;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ }
+
+ td {
+ padding: 6px 10px;
+
+ &.value {
+ word-break: break-all;
+ }
+ }
+
+ tbody {
+ tr:nth-child(odd) {
+ background-color: ${(props) => props.theme.table.striped};
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/index.js
new file mode 100644
index 000000000..7df1f5b45
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/ResponseTrailers/index.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import StyledWrapper from './StyledWrapper';
+
+const ResponseTrailers = ({ trailers }) => {
+ const trailersArray = Array.isArray(trailers) ? trailers : [];
+
+ return (
+
+
+
+
+ Name
+ Value
+
+
+
+ {trailersArray && trailersArray.length ? (
+ trailersArray.map((trailer, index) => (
+
+ {trailer.name}
+ {trailer.value}
+
+ ))
+ ) : (
+
+
+ No trailers received
+
+
+ )}
+
+
+
+ );
+};
+
+export default ResponseTrailers;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js
new file mode 100644
index 000000000..e4e358af4
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/StyledWrapper.js
@@ -0,0 +1,58 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ height: 100%;
+ overflow: hidden;
+ background: ${(props) => props.theme.bg};
+ border-radius: 4px;
+
+ div.tabs {
+ div.tab {
+ padding: 6px 0px;
+ border: none;
+ border-bottom: solid 2px transparent;
+ margin-right: 1.25rem;
+ color: var(--color-tab-inactive);
+ cursor: pointer;
+
+ &:focus,
+ &:active,
+ &:focus-within,
+ &:focus-visible,
+ &:target {
+ outline: none !important;
+ box-shadow: none !important;
+ }
+
+ &.active {
+ color: ${(props) => props.theme.tabs.active.color} !important;
+ border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
+ }
+ }
+ }
+
+ .stream-status {
+ display: inline-flex;
+ align-items: center;
+
+ &.complete {
+ color: ${(props) => props.theme.colors.text.green};
+ }
+
+ &.cancelled {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+
+ &.streaming {
+ color: ${(props) => props.theme.colors.text.blue};
+ }
+ }
+
+ .message-counter {
+ display: inline-flex;
+ align-items: center;
+ margin-left: 10px;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js
new file mode 100644
index 000000000..81ab5bc1e
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/GrpcResponsePane/index.js
@@ -0,0 +1,160 @@
+import React, { useState, useEffect } 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 GrpcResponseHeaders from './GrpcResponseHeaders';
+import GrpcStatusCode from './GrpcStatusCode';
+import ResponseTime from '../ResponseTime/index';
+import Timeline from '../Timeline';
+import ClearTimeline from '../ClearTimeline';
+import ResponseSave from '../ResponseSave';
+import ResponseClear from '../ResponseClear';
+import StyledWrapper from './StyledWrapper';
+import ResponseTrailers from './ResponseTrailers';
+import GrpcQueryResult from './GrpcQueryResult';
+import ResponseLayoutToggle from '../ResponseLayoutToggle';
+import Tab from 'components/Tab';
+
+const GrpcResponsePane = ({ item, collection }) => {
+ const dispatch = useDispatch();
+ const tabs = useSelector((state) => state.tabs.tabs);
+ const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
+ const isLoading = ['queued', 'sending'].includes(item.requestState);
+
+ const requestTimeline = [...(collection?.timeline || [])].filter((obj) => {
+ if (obj.itemUid === item.uid) return true;
+ });
+
+ const selectTab = (tab) => {
+ dispatch(
+ updateResponsePaneTab({
+ uid: item.uid,
+ responsePaneTab: tab
+ })
+ );
+ };
+
+ const response = item.response || {};
+
+ const getTabPanel = (tab) => {
+ switch (tab) {
+ case 'response': {
+ return ;
+ }
+ case 'headers': {
+ return ;
+ }
+ case 'trailers': {
+ return ;
+ }
+ case 'timeline': {
+ return ;
+ }
+ default: {
+ return 404 | Not found
;
+ }
+ }
+ };
+
+ if (isLoading && !item.response) {
+ return (
+
+
+
+ );
+ }
+
+ if (!item.response && !requestTimeline?.length) {
+ return (
+
+
+
+ );
+ }
+
+ if (!activeTabUid) {
+ return Something went wrong
;
+ }
+
+ const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
+ if (!focusedTab || !focusedTab.uid || !focusedTab.responsePaneTab) {
+ return An error occurred!
;
+ }
+
+ const tabConfig = [
+ {
+ name: 'response',
+ label: 'Response',
+ count: Array.isArray(response.responses) ? response.responses.length : 0
+ },
+ {
+ name: 'headers',
+ label: 'Metadata',
+ count: Array.isArray(response.metadata) ? response.metadata.length : 0
+ },
+ {
+ name: 'trailers',
+ label: 'Trailers',
+ count: Array.isArray(response.trailers) ? response.trailers.length : 0
+ },
+ {
+ name: 'timeline',
+ label: 'Timeline'
+ }
+ ];
+
+ return (
+
+
+ {tabConfig.map((tab) => (
+
+ ))}
+ {!isLoading ? (
+
+ {focusedTab?.responsePaneTab === 'timeline' ? (
+ <>
+
+
+ >
+ ) : item?.response ? (
+ <>
+
+
+
+
+ >
+ ) : null}
+
+ ) : null}
+
+
+ {isLoading ? : null}
+ {!item?.response ? (
+ focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
+
+ ) : null
+ ) : (
+ <>{getTabPanel(focusedTab.responsePaneTab)}>
+ )}
+
+
+ );
+};
+
+export default GrpcResponsePane;
diff --git a/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/StyledWrapper.js
new file mode 100644
index 000000000..0a5339df8
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/StyledWrapper.js
@@ -0,0 +1,65 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ .warning-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-bottom: 1.5rem;
+ margin-top: 10%;
+ text-align: center;
+ max-width: 480px;
+ }
+
+ .warning-icon {
+ margin-bottom: 1rem;
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+
+ .warning-title {
+ font-weight: 600;
+ color: ${(props) => props.theme.text};
+ margin-bottom: 1rem;
+ }
+
+ .warning-description {
+ color: ${(props) => props.theme.colors.text.muted};
+
+ .size-highlight {
+ padding: 0.125rem 0.375rem;
+ border-radius: 4px;
+ font-size: 0.8rem;
+ }
+
+ .current-size {
+ color: ${(props) => props.theme.colors.text.danger};
+ background: ${(props) => props.theme.colors.text.danger}15;
+ }
+
+ .supported-size {
+ color: ${(props) => props.theme.colors.text.yellow};
+ background: ${(props) => props.theme.colors.text.yellow}15;
+ }
+ }
+
+ .warning-actions {
+ display: flex;
+ gap: 0.75rem;
+ }
+
+ button {
+ align-items: center;
+ display: flex;
+ gap: 0.5rem;
+ background: ${(props) => props.theme.button.secondary.bg};
+ border-radius: 4px;
+ padding: 0.5rem 1rem;
+ cursor: pointer;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js
new file mode 100644
index 000000000..1686b4a38
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/LargeResponseWarning/index.js
@@ -0,0 +1,92 @@
+import React from 'react';
+import { IconDownload, IconCopy, IconEye, IconAlertTriangle } from '@tabler/icons';
+import toast from 'react-hot-toast';
+import get from 'lodash/get';
+import StyledWrapper from './StyledWrapper';
+import { formatSize } from 'utils/common/index';
+
+const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => {
+ const { ipcRenderer } = window;
+ const response = item.response || {};
+
+ const saveResponseToFile = () => {
+ return new Promise((resolve, reject) => {
+ ipcRenderer
+ .invoke('renderer:save-response-to-file', response, item.requestSent.url)
+ .then(() => {
+ toast.success('Response saved to file');
+ resolve();
+ })
+ .catch((err) => {
+ toast.error(get(err, 'error.message') || 'Something went wrong!');
+ reject(err);
+ });
+ });
+ };
+
+ const copyResponse = () => {
+ try {
+ const textToCopy = typeof response.data === 'string'
+ ? response.data
+ : JSON.stringify(response.data, null, 2);
+
+ navigator.clipboard.writeText(textToCopy).then(() => {
+ toast.success('Response copied to clipboard');
+ }).catch(() => {
+ toast.error('Failed to copy response');
+ });
+ } catch (error) {
+ toast.error('Failed to copy response');
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ Large Response Warning
+
+
+ Handling responses over {formatSize(10 * 1024 * 1024)} could degrade performance.
+
+ Size of current response: {formatSize(responseSize)}
+
+
+
+
+
+
+ View
+
+
+
+ Save
+
+
+
+ Copy
+
+
+
+ );
+};
+
+export default LargeResponseWarning;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js
index 045a9dcc3..e60eb7024 100644
--- a/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ResponsePane/Overlay/StyledWrapper.js
@@ -22,6 +22,15 @@ const StyledWrapper = styled.div`
animation: rotateCounterClockwise 1s linear infinite;
}
}
+
+ // spinner and request time content looks better centered vertically in vertical layout
+ // while in horizontal layout, it looks better when the content is aligned to the top
+ &.vertical-layout {
+ div.overlay {
+ justify-content: center;
+ padding: 1rem;
+ }
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js
index 91fb02d78..429c4889a 100644
--- a/packages/bruno-app/src/components/ResponsePane/Overlay/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/Overlay/index.js
@@ -1,23 +1,25 @@
import React from 'react';
import { IconRefresh } from '@tabler/icons';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { cancelRequest } from 'providers/ReduxStore/slices/collections/actions';
import StopWatch from '../../StopWatch';
import StyledWrapper from './StyledWrapper';
const ResponseLoadingOverlay = ({ item, collection }) => {
const dispatch = useDispatch();
+ const preferences = useSelector((state) => state.app.preferences);
+ const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const handleCancelRequest = () => {
dispatch(cancelRequest(item.cancelTokenUid, item, collection));
};
return (
-
+
diff --git a/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js
index f6d7a09c5..369637b01 100644
--- a/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ResponsePane/Placeholder/StyledWrapper.js
@@ -1,12 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
padding-top: 20%;
width: 100%;
.send-icon {
color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
}
+
+ &.vertical-layout {
+ padding: 1rem;
+ justify-content: center;
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js b/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js
index bca9e138a..4a315ca26 100644
--- a/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/Placeholder/index.js
@@ -1,5 +1,6 @@
import React from 'react';
import { IconSend } from '@tabler/icons';
+import { useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { isMacOS } from 'utils/common/platform';
@@ -8,9 +9,11 @@ const Placeholder = () => {
const sendRequestShortcut = isMac ? 'Cmd + Enter' : 'Ctrl + Enter';
const newRequestShortcut = isMac ? 'Cmd + B' : 'Ctrl + B';
const editEnvironmentShortcut = isMac ? 'Cmd + E' : 'Ctrl + E';
+ const preferences = useSelector((state) => state.app.preferences);
+ const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
return (
-
+
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js
index d4fbee07e..595c170e2 100644
--- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultPreview/index.js
@@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
+import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
+import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { Document, Page } from 'react-pdf';
import 'pdfjs-dist/build/pdf.worker';
@@ -51,6 +53,10 @@ const QueryResultPreview = ({
displayedTheme
}) => {
const preferences = useSelector((state) => state.app.preferences);
+ const tabs = useSelector((state) => state.tabs.tabs);
+ const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
+ const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
+
const dispatch = useDispatch();
const [numPages, setNumPages] = useState(null);
@@ -66,9 +72,19 @@ const QueryResultPreview = ({
if (disableRunEventListener) {
return;
}
+
dispatch(sendRequest(item, collection.uid));
};
+ const onScroll = (event) => {
+ dispatch(
+ updateResponsePaneScrollPosition({
+ uid: focusedTab.uid,
+ scrollY: event.doc.scrollTop
+ })
+ );
+ };
+
switch (previewTab?.mode) {
case 'preview-web': {
const webViewSrc = data.replace('', ` `);
@@ -111,8 +127,10 @@ const QueryResultPreview = ({
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
+ onScroll={onScroll}
value={formattedData}
mode={mode}
+ initialScroll={focusedTab.responsePaneScrollPosition || 0}
readOnly
/>
);
diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js
index b6b8d751d..c3e43fd12 100644
--- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js
@@ -3,37 +3,36 @@ import QueryResultFilter from './QueryResultFilter';
import { JSONPath } from 'jsonpath-plus';
import React from 'react';
import classnames from 'classnames';
+import iconv from 'iconv-lite';
import { getContentType, safeStringifyJSON, safeParseXML } from 'utils/common';
import { getCodeMirrorModeBasedOnContentType } from 'utils/common/codemirror';
import QueryResultPreview from './QueryResultPreview';
-
import StyledWrapper from './StyledWrapper';
-import { useState } from 'react';
-import { useMemo } from 'react';
-import { useEffect } from 'react';
+import { useState, useMemo, useEffect } from 'react';
import { useTheme } from 'providers/Theme/index';
-import { uuid } from 'utils/common/index';
+import { getEncoding, uuid } from 'utils/common/index';
+import LargeResponseWarning from '../LargeResponseWarning';
-const formatResponse = (data, mode, filter) => {
- if (data === undefined) {
+const formatResponse = (data, dataBuffer, encoding, mode, filter) => {
+ if (data === undefined || !dataBuffer || !mode) {
return '';
}
- if (data === null) {
- return 'null';
- }
+ // TODO: We need a better way to get the raw response-data here instead
+ // of using this dataBuffer param.
+ // Also, we only need the raw response-data and content-type to show the preview.
+ const rawData = iconv.decode(
+ Buffer.from(dataBuffer, "base64"),
+ iconv.encodingExists(encoding) ? encoding : "utf-8"
+ );
if (mode.includes('json')) {
- let isValidJSON = false;
-
try {
- isValidJSON = typeof JSON.parse(JSON.stringify(data)) === 'object'
+ JSON.parse(rawData);
} catch (error) {
- console.log('Error parsing JSON: ', error.message);
- }
-
- if (!isValidJSON && typeof data === 'string') {
- return data;
+ // If the response content-type is JSON and it fails parsing, its an invalid JSON.
+ // In that case, just show the response as it is in the preview.
+ return rawData;
}
if (filter) {
@@ -62,13 +61,56 @@ const formatResponse = (data, mode, filter) => {
return safeStringifyJSON(data, true);
};
-const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEventListener, headers, error }) => {
+const formatErrorMessage = (error) => {
+ if (!error) return 'Something went wrong';
+
+ const remoteMethodError = "Error invoking remote method 'send-http-request':";
+
+ if (error?.includes(remoteMethodError)) {
+ const parts = error.split(remoteMethodError);
+ return parts[1]?.trim() || error;
+ }
+
+ return error;
+};
+
+const QueryResult = ({ item, collection, data, dataBuffer, disableRunEventListener, headers, error }) => {
const contentType = getContentType(headers);
const mode = getCodeMirrorModeBasedOnContentType(contentType, data);
const [filter, setFilter] = useState(null);
- const formattedData = formatResponse(data, mode, filter);
+ const [showLargeResponse, setShowLargeResponse] = useState(false);
+ const responseEncoding = getEncoding(headers);
const { displayedTheme } = useTheme();
+ const responseSize = useMemo(() => {
+ const response = item.response || {};
+ if (typeof response.size === 'number') {
+ return response.size;
+ }
+
+ if (!dataBuffer) return 0;
+
+ try {
+ // dataBuffer is base64 encoded, so we need to calculate the actual size
+ const buffer = Buffer.from(dataBuffer, 'base64');
+ return buffer.length;
+ } catch (error) {
+ return 0;
+ }
+ }, [dataBuffer, item.response]);
+
+ const isLargeResponse = responseSize > 10 * 1024 * 1024; // 10 MB
+
+ const formattedData = useMemo(
+ () => {
+ if (isLargeResponse && !showLargeResponse) {
+ return '';
+ }
+ return formatResponse(data, dataBuffer, responseEncoding, mode, filter);
+ },
+ [data, dataBuffer, responseEncoding, mode, filter, isLargeResponse, showLargeResponse]
+ );
+
const debouncedResultFilterOnChange = debounce((e) => {
setFilter(e.target.value);
}, 250);
@@ -77,7 +119,9 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
// Always show raw
const allowedPreviewModes = [{ mode: 'raw', name: 'Raw', uid: uuid() }];
- if (mode.includes('html') && typeof data === 'string') {
+ if (!mode || !contentType) return allowedPreviewModes;
+
+ if (mode?.includes('html') && typeof data === 'string') {
allowedPreviewModes.unshift({ mode: 'preview-web', name: 'Web', uid: uuid() });
} else if (mode.includes('image')) {
allowedPreviewModes.unshift({ mode: 'preview-image', name: 'Image', uid: uuid() });
@@ -121,11 +165,11 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
}, [allowedPreviewModes, previewTab]);
const queryFilterEnabled = useMemo(() => mode.includes('json'), [mode]);
+ const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
return (
@@ -133,7 +177,9 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
{error ? (
-
{error}
+ {hasScriptError ? null : (
+
{formatErrorMessage(error)}
+ )}
{error && typeof error === 'string' && error.toLowerCase().includes('self signed certificate') ? (
@@ -142,25 +188,33 @@ const QueryResult = ({ item, collection, data, dataBuffer, width, disableRunEven
) : null}
+ ) : isLargeResponse && !showLargeResponse ? (
+ setShowLargeResponse(true)}
+ />
) : (
- <>
-
- {queryFilterEnabled && (
-
- )}
- >
+
+
+
+ {queryFilterEnabled && (
+
+ )}
+
+
)}
);
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js
new file mode 100644
index 000000000..8cb5d4b43
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/StyledWrapper.js
@@ -0,0 +1,15 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ button {
+ display: flex;
+ align-items: center;
+ padding: 0.25rem;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+`;
+
+export default Wrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js
new file mode 100644
index 000000000..49299422b
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { savePreferences } from 'providers/ReduxStore/slices/app';
+import StyledWrapper from './StyledWrapper';
+
+const IconDockToBottom = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+const IconDockToRight = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+const ResponseLayoutToggle = () => {
+ const dispatch = useDispatch();
+ const preferences = useSelector((state) => state.app.preferences);
+ const orientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
+
+ const toggleOrientation = () => {
+ const newOrientation = orientation === 'horizontal' ? 'vertical' : 'horizontal';
+ const updatedPreferences = {
+ ...preferences,
+ layout: {
+ ...preferences.layout,
+ responsePaneOrientation: newOrientation
+ }
+ };
+ dispatch(savePreferences(updatedPreferences));
+ };
+
+ return (
+
+
+ {orientation === 'horizontal' ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default ResponseLayoutToggle;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js
new file mode 100644
index 000000000..0dd1c7b1a
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseLayoutToggle/index.spec.js
@@ -0,0 +1,173 @@
+import '@testing-library/jest-dom';
+import React from 'react';
+import { render, screen, fireEvent} from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { ThemeProvider } from 'providers/Theme';
+import { configureStore, createSlice } from '@reduxjs/toolkit';
+import ResponseLayoutToggle from './index';
+
+const mockSavePreferences = jest.fn((payload) => ({ type: 'app/savePreferences', payload }));
+
+// Mock the savePreferences action
+jest.mock('providers/ReduxStore/slices/app', () => ({
+ savePreferences: (payload) => mockSavePreferences(payload)
+}));
+
+// Mock localStorage
+const mockLocalStorage = {
+ getItem: jest.fn(() => 'dark'),
+ setItem: jest.fn(),
+ removeItem: jest.fn()
+};
+
+// Mock matchMedia
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn()
+ })),
+ });
+ Object.defineProperty(window, 'localStorage', {
+ value: mockLocalStorage
+ });
+});
+
+beforeEach(() => {
+ mockSavePreferences.mockClear();
+});
+
+const initialState = {
+ app: {
+ preferences: {
+ layout: {
+ responsePaneOrientation: 'horizontal'
+ }
+ }
+ }
+};
+
+const createTestStore = (initialState) => {
+ const appSlice = createSlice({
+ name: 'app',
+ initialState: initialState.app,
+ reducers: {
+ savePreferences: (state, action) => {
+ state.preferences = action.payload;
+ }
+ }
+ });
+
+ return configureStore({
+ reducer: { app: appSlice.reducer }
+ });
+};
+
+const renderWithProviders = (component, customState = initialState) => {
+ const store = createTestStore(customState);
+ return {
+ store,
+ ...render(
+
+
+ {component}
+
+
+ )
+ };
+};
+
+describe('ResponseLayoutToggle', () => {
+ describe('Initial Render', () => {
+ it('should render with horizontal orientation by default', () => {
+ renderWithProviders( );
+ const button = screen.getByRole('button');
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveAttribute('title', 'Switch to vertical layout');
+ });
+
+ it('should render with vertical orientation when specified', () => {
+ const customState = {
+ app: {
+ preferences: {
+ layout: {
+ responsePaneOrientation: 'vertical'
+ }
+ }
+ }
+ };
+ renderWithProviders( , customState);
+ const button = screen.getByRole('button');
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
+ });
+ });
+
+ describe('Interaction', () => {
+ it('should switch to vertical layout when clicked in horizontal mode', () => {
+ const { store } = renderWithProviders( );
+ const button = screen.getByRole('button');
+
+ // Initial state check
+ expect(button).toHaveAttribute('title', 'Switch to vertical layout');
+
+ fireEvent.click(button);
+
+ // Check if action was called
+ expect(mockSavePreferences).toHaveBeenCalledWith({
+ layout: {
+ responsePaneOrientation: 'vertical'
+ }
+ });
+
+ // Manually update store to simulate state change
+ store.dispatch(mockSavePreferences({
+ layout: {
+ responsePaneOrientation: 'vertical'
+ }
+ }));
+
+ // Check if button title was updated
+ expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
+ });
+
+ it('should switch to horizontal layout when clicked in vertical mode', () => {
+ const customState = {
+ app: {
+ preferences: {
+ layout: {
+ responsePaneOrientation: 'vertical'
+ }
+ }
+ }
+ };
+ const { store } = renderWithProviders( , customState);
+ const button = screen.getByRole('button');
+
+ // Initial state check
+ expect(button).toHaveAttribute('title', 'Switch to horizontal layout');
+
+ fireEvent.click(button);
+
+ // Check if action was called
+ expect(mockSavePreferences).toHaveBeenCalledWith({
+ layout: {
+ responsePaneOrientation: 'horizontal'
+ }
+ });
+
+ // Manually update store to simulate state change
+ store.dispatch(mockSavePreferences({
+ layout: {
+ responsePaneOrientation: 'horizontal'
+ }
+ }));
+
+ // Check if button title was updated
+ expect(button).toHaveAttribute('title', 'Switch to vertical layout');
+ });
+ });
+});
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js
new file mode 100644
index 000000000..ef46d4361
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/ResponseSize.spec.js
@@ -0,0 +1,110 @@
+import '@testing-library/jest-dom';
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { ThemeProvider } from 'styled-components';
+import ResponseSize from './index';
+
+// Create minimal theme with only the properties needed for the component
+const theme = {
+ requestTabPanel: {
+ responseStatus: '#666'
+ }
+};
+
+// Wrap component with theme provider for styled-components
+const renderWithTheme = (component) => {
+ return render(
+
+ {component}
+
+ );
+};
+
+describe('ResponseSize', () => {
+ describe('Invalid or excluded size values', () => {
+ it('should not render when size is undefined', () => {
+ const { container } = renderWithTheme( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is null', () => {
+ const { container } = renderWithTheme( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is NaN', () => {
+ const { container } = renderWithTheme( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is Infinity', () => {
+ const { container } = renderWithTheme( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is -Infinity', () => {
+ const { container } = renderWithTheme( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is a string', () => {
+ const { container } = renderWithTheme( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('should not render when size is an object', () => {
+ const { container } = renderWithTheme( );
+ expect(container).toBeEmptyDOMElement();
+ });
+ });
+
+ describe('Valid size values', () => {
+ it('should handle zero bytes', () => {
+ renderWithTheme( );
+ const element = screen.getByText(/0B/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^0B$/);
+ expect(element).toHaveAttribute('title', '0B');
+ });
+
+ it('should render bytes when size is less than 1024', () => {
+ renderWithTheme( );
+ const element = screen.getByText(/500B/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^500B$/);
+ expect(element).toHaveAttribute('title', '500B');
+ });
+
+ it('should handle exactly 1024 bytes as size', () => {
+ renderWithTheme( );
+ const element = screen.getByText(/1024B/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^1024B$/);
+ expect(element).toHaveAttribute('title', '1,024B');
+ });
+
+ it('should render kilobytes when size is greater than 1024', () => {
+ renderWithTheme( );
+ const element = screen.getByText(/1\.46KB/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
+ expect(element).toHaveAttribute('title', '1,500B');
+ });
+
+ it('should handle large size numbers', () => {
+ renderWithTheme( );
+ const element = screen.getByText(/10\.0KB/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
+ expect(element).toHaveAttribute('title', '10,240B');
+ });
+
+ it('should handle decimal size numbers', () => {
+ renderWithTheme( );
+ const element = screen.getByText(/1\.10KB/);
+ expect(element).toBeInTheDocument();
+ expect(element.textContent).toMatch(/^\d+\.\d+KB$/);
+ expect(element).toHaveAttribute('title', '1,126.5B');
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js
index b956b0813..fcdeaaca3 100644
--- a/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseSize/index.js
@@ -2,19 +2,25 @@ import React from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseSize = ({ size }) => {
+
+ if (!Number.isFinite(size)) {
+ return null;
+ }
+
let sizeToDisplay = '';
+ // If size is greater than 1024 bytes, format as KB
if (size > 1024) {
- // size is greater than 1kb
let kb = Math.floor(size / 1024);
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
sizeToDisplay = kb + '.' + decimal + 'KB';
} else {
+ // If size is less than or equal to 1024 bytes, display as bytes (B)
sizeToDisplay = size + 'B';
}
return (
-
+
{sizeToDisplay}
);
diff --git a/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js b/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js
index ed05e944c..52b8b84a3 100644
--- a/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/ResponseTime/index.js
@@ -1,9 +1,9 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
+import isNumber from 'lodash/isNumber';
const ResponseTime = ({ duration }) => {
let durationToDisplay = '';
-
if (duration > 1000) {
// duration greater than a second
let seconds = Math.floor(duration / 1000);
@@ -13,6 +13,10 @@ const ResponseTime = ({ duration }) => {
durationToDisplay = duration + 'ms';
}
+ if (!isNumber(duration)) {
+ return null;
+ }
+
return {durationToDisplay} ;
};
export default ResponseTime;
diff --git a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js
new file mode 100644
index 000000000..4b7cb28a7
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/StyledWrapper.js
@@ -0,0 +1,151 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .timeline-event {
+ padding: 8px 0 0 0;
+ cursor: pointer;
+ }
+
+ .timeline-event-content {
+ border-radius: 4px;
+ padding: 12px;
+ margin-top: 0.5rem;
+ }
+
+ .timeline-event-header {
+ color: ${(props) => props.theme.text};
+ }
+
+ .method-label {
+ font-weight: 600;
+ }
+
+ .status-code {
+ font-weight: 600;
+ }
+
+ .url-text {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ margin-top: 0.25rem;
+ }
+
+ .timestamp {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ }
+
+ .meta-info {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ }
+
+ .oauth-section {
+ .oauth-header {
+ display: flex;
+ align-items: center;
+ color: ${(props) => props.theme.text};
+ font-weight: 600;
+
+ span {
+ margin-left: 0.5rem;
+ }
+ }
+ }
+
+ .tabs-switcher {
+ border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
+ margin-bottom: 16px;
+
+ button {
+ position: relative;
+ padding: 8px 16px;
+ color: ${(props) => props.theme.colors.text.muted};
+
+ &.active {
+ color: ${(props) => props.theme.tabs.active.color};
+ &:after {
+ content: '';
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: ${(props) => props.theme.tabs.active.border};
+ }
+ }
+ }
+ }
+
+ .network-logs {
+ background: ${(props) => props.theme.codemirror.bg};
+ color: ${(props) => props.theme.text};
+ border-radius: 4px;
+ }
+
+ .oauth-request-item-content {
+ border-radius: 4px;
+ margin-top: 0.5rem;
+ }
+
+ .collapsible-section {
+ margin-bottom: 12px;
+
+ .section-header {
+ cursor: pointer;
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+ }
+
+ .line {
+ white-space: pre-line;
+ word-wrap: break-word;
+ word-break: break-all;
+ font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important;
+
+ .arrow {
+ opacity: 0.5;
+ }
+
+ &.request {
+ color: ${(props) => props.theme.colors.text.green};
+ }
+
+ &.response {
+ color: ${(props) => props.theme.colors.text.purple};
+ }
+ }
+
+ .request-label {
+ font-size: 0.75rem;
+ padding: 2px 6px;
+ border-radius: 3px;
+ margin-left: 8px;
+ background: ${(props) => props.theme.requestTabs.bg};
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ td {
+ padding: 6px 10px;
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js
new file mode 100644
index 000000000..4fac7ed6d
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/RunnerTimeline/index.js
@@ -0,0 +1,70 @@
+import React, { useMemo } from 'react';
+import forOwn from 'lodash/forOwn';
+import StyledWrapper from './StyledWrapper';
+import TimelineItem from '../Timeline/TimelineItem';
+
+const RunnerTimeline = ({ request = {}, response = {}, item, collection }) => {
+ const requestHeaders = [];
+
+ forOwn(request.headers, (value, key) => {
+ requestHeaders.push({
+ name: key,
+ value
+ });
+ });
+
+ const oauth2Events = useMemo(
+ () =>
+ collection?.timeline?.filter(
+ (event) => event.type === 'oauth2' && event.itemUid === item.uid
+ ) || [],
+ [collection?.timeline, item.uid]
+ );
+
+ return (
+
+ {/* Show the main request/response timeline item */}
+
+
+ {oauth2Events.map((event, index) => {
+ const { data, timestamp } = event;
+ const { debugInfo } = data;
+ return (
+
+
+
+ {debugInfo && debugInfo.length > 0 ? (
+ debugInfo.map((data, idx) => (
+
+
+
+ ))
+ ) : (
+
No debug information available.
+ )}
+
+
+ );
+ })}
+
+ );
+};
+
+export default RunnerTimeline;
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js
new file mode 100644
index 000000000..c4a38e80f
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/StyledWrapper.js
@@ -0,0 +1,55 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ border-left: 4px solid ${(props) => props.theme.colors.text.danger};
+ border-top: 1px solid transparent;
+ border-right: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ border-radius: 0.375rem;
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ max-height: 200px;
+ min-height: 70px;
+ overflow-y: auto;
+ background-color: ${(props) => props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.5)' : 'rgba(250, 250, 250, 0.9)'};
+
+ .error-icon-container {
+ margin-top: 0.125rem;
+ padding: 0.375rem;
+ border-radius: 9999px;
+ background-color: ${(props) => props.theme.bg === '#1e1e1e' ? 'rgba(40, 40, 40, 0.8)' : 'rgba(240, 240, 240, 0.8)'};
+
+ svg {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+ }
+
+ .close-button {
+ opacity: 0.7;
+ transition: opacity 0.2s;
+
+ &:hover {
+ opacity: 1;
+ }
+
+ svg {
+ color: ${(props) => props.theme.text};
+ }
+ }
+
+ .error-title {
+ font-weight: 600;
+ margin-bottom: 0.375rem;
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+
+ .error-message {
+ font-family: monospace;
+ font-size: 0.6875rem;
+ line-height: 1.25rem;
+ white-space: pre-wrap;
+ word-break: break-all;
+ color: ${(props) => props.theme.text};
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
new file mode 100644
index 000000000..1db032d2e
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptError/index.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import { IconX } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+
+const ScriptError = ({ item, onClose }) => {
+ const preRequestError = item?.preRequestScriptErrorMessage;
+ const postResponseError = item?.postResponseScriptErrorMessage;
+ const testScriptError = item?.testScriptErrorMessage;
+
+ if (!preRequestError && !postResponseError && !testScriptError) return null;
+
+ const errors = [];
+
+ if (preRequestError) {
+ errors.push({
+ title: 'Pre-Request Script Error',
+ message: preRequestError
+ });
+ }
+
+ if (postResponseError) {
+ errors.push({
+ title: 'Post-Response Script Error',
+ message: postResponseError
+ });
+ }
+
+ if (testScriptError) {
+ errors.push({
+ title: 'Test Script Error',
+ message: testScriptError
+ });
+ }
+
+ return (
+
+
+
+ {errors.map((error, index) => (
+
+ {index > 0 &&
}
+
+ {error.title}
+
+
+ {error.message}
+
+
+ ))}
+
+
+
+
+
+
+ );
+};
+
+export default ScriptError;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js b/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js
new file mode 100644
index 000000000..208a49ffd
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/ScriptErrorIcon/index.js
@@ -0,0 +1,28 @@
+import React from 'react';
+import { IconAlertCircle } from '@tabler/icons';
+import ToolHint from 'components/ToolHint';
+
+const ScriptErrorIcon = ({ itemUid, onClick }) => {
+ const toolhintId = `script-error-icon-${itemUid}`;
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default ScriptErrorIcon;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/SkippedRequest/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/SkippedRequest/StyledWrapper.js
new file mode 100644
index 000000000..a7049ad6b
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/SkippedRequest/StyledWrapper.js
@@ -0,0 +1,11 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ padding-top: 20%;
+ width: 100%;
+ .send-icon {
+ color: ${(props) => props.theme.requestTabPanel.responseSendIcon};
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/SkippedRequest/index.js b/packages/bruno-app/src/components/ResponsePane/SkippedRequest/index.js
new file mode 100644
index 000000000..684dc3c37
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/SkippedRequest/index.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import { IconCircleOff } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+const SkippedRequest = () => {
+ return (
+
+
+
+
+
+ Request skipped
+
+
+ );
+};
+
+export default SkippedRequest;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/TestResults/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/TestResults/StyledWrapper.js
index 13fa41142..5b029386e 100644
--- a/packages/bruno-app/src/components/ResponsePane/TestResults/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ResponsePane/TestResults/StyledWrapper.js
@@ -1,6 +1,18 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
+ color: ${(props) => props.theme.text};
+
+ .test-summary {
+ transition: background-color 0.2s;
+ border-bottom: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
+ color: ${(props) => props.theme.text};
+
+ &:hover {
+ background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
+ }
+ }
+
.test-success {
color: ${(props) => props.theme.colors.text.green};
}
@@ -9,9 +21,25 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
}
+ .test-success-count {
+ color: ${(props) => props.theme.colors.text.green};
+ }
+
+ .test-failure-count {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+
.error-message {
color: ${(props) => props.theme.colors.text.muted};
}
+
+ .test-results-list {
+ transition: all 0.3s ease;
+ }
+
+ .dropdown-icon {
+ color: ${(props) => props.theme.sidebar.dropdownIcon.color};
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js
index 074fac9e1..624de837d 100644
--- a/packages/bruno-app/src/components/ResponsePane/TestResults/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/TestResults/index.js
@@ -1,63 +1,151 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
import StyledWrapper from './StyledWrapper';
+import {
+ IconChevronDown,
+ IconChevronRight,
+ IconCircleCheck,
+ IconCircleX
+} from '@tabler/icons';
-const TestResults = ({ results, assertionResults }) => {
+const ResultIcon = ({ status }) => (
+
+ {status === 'pass' ? (
+
+ ) : (
+
+ )}
+
+);
+
+const ErrorMessage = ({ error }) => error && (
+ <>
+
+
+ {error}
+
+ >
+);
+
+const ResultItem = ({ result, type }) => (
+
+
+
+ {type === 'assertion'
+ ? `${result.lhsExpr}: ${result.rhsExpr}`
+ : result.description
+ }
+
+
+
+);
+
+const TestSection = ({
+ title,
+ results,
+ isExpanded,
+ onToggle,
+ type = 'test'
+}) => {
+ const passedResults = results.filter((result) => result.status === 'pass');
+ const failedResults = results.filter((result) => result.status === 'fail');
+
+ if (results.length === 0) return null;
+
+ return (
+
+
+
+ {isExpanded ?
+ :
+
+ }
+
+
+ {title} ({results.length}), Passed: {passedResults.length}, Failed: {failedResults.length}
+
+
+ {isExpanded && (
+
+ {results.map((result) => (
+
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
results = results || [];
assertionResults = assertionResults || [];
- if (!results.length && !assertionResults.length) {
- return No tests found
;
+ preRequestTestResults = preRequestTestResults || [];
+ postResponseTestResults = postResponseTestResults || [];
+
+ const [expandedSections, setExpandedSections] = useState({
+ preRequest: true,
+ tests: true,
+ postResponse: true,
+ assertions: true
+ });
+
+ useEffect(() => {
+ setExpandedSections({
+ preRequest: preRequestTestResults.length > 0,
+ tests: results.length > 0,
+ postResponse: postResponseTestResults.length > 0,
+ assertions: assertionResults.length > 0
+ });
+ }, [results.length, assertionResults.length, preRequestTestResults.length, postResponseTestResults.length]);
+
+ const toggleSection = (section) => {
+ setExpandedSections({
+ ...expandedSections,
+ [section]: !expandedSections[section]
+ });
+ };
+
+ if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
+ return No tests found
;
}
- const passedTests = results.filter((result) => result.status === 'pass');
- const failedTests = results.filter((result) => result.status === 'fail');
-
- const passedAssertions = assertionResults.filter((result) => result.status === 'pass');
- const failedAssertions = assertionResults.filter((result) => result.status === 'fail');
-
return (
-
- Tests ({results.length}/{results.length}), Passed: {passedTests.length}, Failed: {failedTests.length}
-
-
- {results.map((result) => (
-
- {result.status === 'pass' ? (
- ✔ {result.description}
- ) : (
- <>
- ✘ {result.description}
-
- {result.error}
- >
- )}
-
- ))}
-
+ toggleSection('preRequest')}
+ type="test"
+ />
-
- Assertions ({assertionResults.length}/{assertionResults.length}), Passed: {passedAssertions.length}, Failed:{' '}
- {failedAssertions.length}
-
-
- {assertionResults.map((result) => (
-
- {result.status === 'pass' ? (
-
- ✔ {result.lhsExpr}: {result.rhsExpr}
-
- ) : (
- <>
-
- ✘ {result.lhsExpr}: {result.rhsExpr}
-
-
- {result.error}
- >
- )}
-
- ))}
-
+ toggleSection('postResponse')}
+ type="test"
+ />
+
+ toggleSection('tests')}
+ type="test"
+ />
+
+ toggleSection('assertions')}
+ type="assertion"
+ />
);
};
diff --git a/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js b/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js
index f894d1f76..51d6f94cc 100644
--- a/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/TestResultsLabel/index.js
@@ -1,9 +1,13 @@
import React from 'react';
+import { IconCircleCheck, IconCircleX } from '@tabler/icons';
-const TestResultsLabel = ({ results, assertionResults }) => {
+const TestResultsLabel = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
results = results || [];
assertionResults = assertionResults || [];
- if (!results.length && !assertionResults.length) {
+ preRequestTestResults = preRequestTestResults || [];
+ postResponseTestResults = postResponseTestResults || [];
+
+ if (!results.length && !assertionResults.length && !preRequestTestResults.length && !postResponseTestResults.length) {
return 'Tests';
}
@@ -13,8 +17,14 @@ const TestResultsLabel = ({ results, assertionResults }) => {
const numberOfAssertions = assertionResults.length;
const numberOfFailedAssertions = assertionResults.filter((result) => result.status === 'fail').length;
- const totalNumberOfTests = numberOfTests + numberOfAssertions;
- const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions;
+ const numberOfPreRequestTests = preRequestTestResults.length;
+ const numberOfFailedPreRequestTests = preRequestTestResults.filter((result) => result.status === 'fail').length;
+
+ const numberOfPostResponseTests = postResponseTestResults.length;
+ const numberOfFailedPostResponseTests = postResponseTestResults.filter((result) => result.status === 'fail').length;
+
+ const totalNumberOfTests = numberOfTests + numberOfAssertions + numberOfPreRequestTests + numberOfPostResponseTests;
+ const totalNumberOfFailedTests = numberOfFailedTests + numberOfFailedAssertions + numberOfFailedPreRequestTests + numberOfFailedPostResponseTests;
return (
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js
new file mode 100644
index 000000000..5e049118e
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js
@@ -0,0 +1,274 @@
+import { useState } from "react";
+import { RelativeTime } from "../TimelineItem/Common/Time/index";
+import Status from "../TimelineItem/Common/Status/index";
+import {
+ IconChevronDown,
+ IconChevronRight,
+ IconServer,
+ IconDatabase,
+ IconAlertCircle,
+ IconCircleCheck,
+ IconCircleX,
+ IconX,
+ IconSend
+} from '@tabler/icons';
+
+// Icons for different event types
+const EventTypeIcons = {
+ metadata:
,
+ response:
,
+ request:
,
+ message:
,
+ status:
,
+ error:
,
+ end:
,
+ cancel:
+};
+
+// Event type display names
+const EventTypeNames = {
+ metadata: "Metadata",
+ response: "Response Message",
+ request: "Request",
+ message: "Message",
+ status: "Status",
+ error: "Error",
+ end: "Stream Ended",
+ cancel: "Cancelled"
+};
+
+// Colors for different event types
+const EventTypeColors = {
+ metadata: "border-blue-500/20",
+ response: "border-green-500/20",
+ request: "border-orange-500/20",
+ message: "border-orange-500/20",
+ status: "border-purple-500/20",
+ error: "border-red-500/20",
+ end: "border-gray-500/20",
+ cancel: "border-amber-500/20"
+};
+
+const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData, item, collection, width }) => {
+ const [isCollapsed, setIsCollapsed] = useState(true);
+ const toggleCollapse = () => setIsCollapsed(prev => !prev);
+
+ // Use requestSent if available, otherwise fall back to request
+ const effectiveRequest = item.requestSent || request || item.request || {};
+
+ // Extract relevant data from request and response
+ const { method, url = '' } = effectiveRequest;
+ const { statusCode, statusText, duration } = response || {};
+
+ // Get event-specific icon and color
+ const eventIcon = EventTypeIcons[eventType] ||
;
+ const eventColor = EventTypeColors[eventType] || "border-gray-500/50";
+ const eventName = EventTypeNames[eventType] || "Event";
+
+
+ // Render appropriate content based on event type
+ const renderEventContent = () => {
+
+ const isClientStreaming = effectiveRequest.methodType === 'client-streaming' || effectiveRequest.methodType === 'bidi-streaming';
+
+ switch(eventType) {
+ case 'request':
+ return (
+
+
+ {effectiveRequest.headers && Object.keys(effectiveRequest.headers).length > 0 && (
+
+
Metadata
+
+ {Object.entries(effectiveRequest.headers).map(([key, value], idx) => (
+
+ ))}
+
+
+ )}
+
+ {/* gRPC Messages section */}
+ {!isClientStreaming && effectiveRequest.body?.mode === 'grpc' && effectiveRequest.body?.grpc?.length > 0 && (
+
+
+ Message
+
+
+ {effectiveRequest.body.grpc.filter((_, index) => index === 0).map((message, idx) => (
+
+
+ {typeof message.content === 'string'
+ ? message.content
+ : JSON.stringify(message.content, null, 2)}
+
+
+ ))}
+
+
+ )}
+
+ );
+
+ case 'message':
+ return (
+
+
Message
+
+ {typeof eventData === 'string'
+ ? eventData
+ : JSON.stringify(eventData, null, 2)}
+
+
+ );
+
+ case 'metadata':
+ return (
+
+
Metadata Headers
+ {response.metadata && response.metadata.length > 0 ? (
+
+ {response.metadata.map((header, idx) => (
+
+
{header.name}:
+
{header.value}
+
+ ))}
+
+ ) : (
+
No metadata headers
+ )}
+
+ );
+
+ case 'response':
+ // For message responses, show the response data
+ return (
+
+
+ Response Message #{(response.responses.length || 0)}
+
+ {response.responses && response.responses.length > 0 ? (
+
+ {JSON.stringify(response.responses[response.responses.length - 1], null, 2)}
+
+ ) : (
+
Empty message
+ )}
+
+ );
+
+ case 'status':
+ // For status events, show status and trailers
+ return (
+
+
+
+
+
+ {response.statusDescription && (
+
{response.statusDescription}
+ )}
+
+ {response.trailers && response.trailers.length > 0 && (
+ <>
+
Trailers
+
+ {response.trailers.map((trailer, idx) => (
+
+
{trailer.name}:
+
{trailer.value || ''}
+
+ ))}
+
+ >
+ )}
+
+ );
+
+ case 'error':
+ // For error events, show error details
+ return (
+
+
Error
+
{response.error || "Unknown error"}
+
+
+
+
+
+ {response.trailers && response.trailers.length > 0 && (
+ <>
+
Error Metadata
+
+ {response.trailers.map((trailer, idx) => (
+
+
{trailer.name}:
+
{trailer.value}
+
+ ))}
+
+ >
+ )}
+
+ );
+
+ case 'end':
+ // For end events, show summary
+ return (
+
+
Stream Ended
+
+ Total messages: {response.responses.length || 0}
+
+
+ );
+
+ case 'cancel':
+ // For cancel events, show cancellation info
+ return (
+
+
Stream Cancelled
+
{response.statusDescription || "The gRPC stream was cancelled"}
+
+ );
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+ {isCollapsed ?
:
}
+ {eventIcon}
+
{eventName}
+ {eventType === 'request' && effectiveRequest.methodType && (
+
+ {effectiveRequest.methodType}
+
+ )}
+ {eventType === 'status' && (
+
+
+
+ )}
+
[{new Date(timestamp).toISOString()}]
+
+
+
+
+
+ {/* Always show the URL */}
+
{url}
+
+ {/* Expanded content - only show for non-status items */}
+ {!isCollapsed && renderEventContent()}
+
+ );
+};
+
+export default GrpcTimelineItem;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js
index 020d5bd91..263d45245 100644
--- a/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/StyledWrapper.js
@@ -1,11 +1,118 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
+ position: relative;
+ overflow-y: auto;
+ height: 100%;
+ flex: 1;
+
+ .timeline-container {
+ flex: 1;
+ }
+
+ .timeline-event {
+ padding: 8px 0 0 0;
+ cursor: pointer;
+ }
+
+ .timeline-event-content {
+ border-radius: 4px;
+ padding: 12px;
+ margin-top: 0.5rem;
+ }
+
+ .timeline-event-header {
+ color: ${(props) => props.theme.text};
+ }
+
+ .method-label {
+ font-weight: 600;
+ }
+
+ .status-code {
+ font-weight: 600;
+ }
+
+ .url-text {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ margin-top: 0.25rem;
+ }
+
+ .timestamp {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ }
+
+ .meta-info {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ }
+
+ .oauth-section {
+ .oauth-header {
+ display: flex;
+ align-items: center;
+ color: ${(props) => props.theme.text};
+ font-weight: 600;
+
+ span {
+ margin-left: 0.5rem;
+ }
+ }
+ }
+
+ .tabs-switcher {
+ border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
+ margin-bottom: 16px;
+
+ button {
+ position: relative;
+ padding: 8px 16px;
+ color: ${(props) => props.theme.colors.text.muted};
+
+ &.active {
+ color: ${(props) => props.theme.tabs.active.color};
+ &:after {
+ content: '';
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: ${(props) => props.theme.tabs.active.border};
+ }
+ }
+ }
+ }
+
+ .network-logs {
+ background: ${(props) => props.theme.codemirror.bg};
+ color: ${(props) => props.theme.text};
+ border-radius: 4px;
+ }
+
+ .oauth-request-item-content {
+ border-radius: 4px;
+ margin-top: 0.5rem;
+ }
+
+ .collapsible-section {
+ margin-bottom: 12px;
+
+ .section-header {
+ cursor: pointer;
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+ }
+
.line {
white-space: pre-line;
word-wrap: break-word;
word-break: break-all;
- font-family: Inter, sans-serif !important;
+ font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important;
.arrow {
opacity: 0.5;
@@ -19,6 +126,35 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.purple};
}
}
+
+ .request-label {
+ font-size: 0.75rem;
+ padding: 2px 6px;
+ border-radius: 3px;
+ margin-left: 8px;
+ background: ${(props) => props.theme.requestTabs.bg};
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ font-weight: 600;
+ table-layout: fixed;
+
+ thead,
+ td {
+ border: 1px solid ${(props) => props.theme.table.border};
+ }
+
+ thead {
+ color: ${(props) => props.theme.table.thead.color};
+ font-size: 0.8125rem;
+ user-select: none;
+ }
+ td {
+ padding: 6px 10px;
+ }
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js
new file mode 100644
index 000000000..683c82bba
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Body/index.js
@@ -0,0 +1,36 @@
+import QueryResult from "components/ResponsePane/QueryResult/index";
+import { useState } from "react";
+
+const BodyBlock = ({ collection, data, dataBuffer, headers, error, item }) => {
+ const [isBodyCollapsed, toggleBody] = useState(true);
+ return (
+
+
toggleBody(!isBodyCollapsed)}>
+
+ {isBodyCollapsed ? '▼' : '▶'}
Body
+
+
+ {isBodyCollapsed && (
+
+ {data || dataBuffer ? (
+
+
+
+ ) : (
+
No Body found
+ )}
+
+ )}
+
+ )
+}
+
+export default BodyBlock;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js
new file mode 100644
index 000000000..bb8448f70
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Headers/index.js
@@ -0,0 +1,54 @@
+import { useState } from "react";
+
+const HeadersBlock = ({ headers, type }) => {
+ const [areHeadersCollapsed, toggleHeaders] = useState(true);
+
+ return (
+
+
toggleHeaders(!areHeadersCollapsed)}>
+
+ {areHeadersCollapsed ? '▼' : '▶'}
Headers
+ {headers && Object.keys(headers).length > 0 &&
+ ({Object.keys(headers).length})
+ }
+
+
+ {areHeadersCollapsed && (
+
+ {headers && Object.keys(headers).length > 0
+ ?
+ :
No Headers found
+ }
+
+ )}
+
+ )
+};
+
+const Headers = ({ headers, type }) => {
+ if (Array.isArray(headers)) {
+ return (
+
+ {headers.map((header, index) => (
+
+ {type === 'request' ? '>' : '<'} {header?.name}:
+ {String(header?.value)}
+
+ ))}
+
+ );
+ } else {
+ return (
+
+ {Object.entries(headers).map(([key, value], index) => (
+
+ {type === 'request' ? '>' : '<'} {key}:
+ {String(value)}
+
+ ))}
+
+ );
+ }
+};
+
+export default HeadersBlock;
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Method/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Method/index.js
new file mode 100644
index 000000000..1e0b22d3a
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Method/index.js
@@ -0,0 +1,19 @@
+const Method = ({ method }) => {
+ return (
+
+ {method?.toUpperCase()}
+
+ )
+}
+
+const methodColors = {
+ GET: 'text-green-500',
+ POST: 'text-blue-500',
+ PUT: 'text-yellow-500',
+ DELETE: 'text-red-500',
+ PATCH: 'text-purple-500',
+ OPTIONS: 'text-gray-500',
+ HEAD: 'text-gray-500',
+};
+
+export default Method;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js
new file mode 100644
index 000000000..8c0094120
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Status/index.js
@@ -0,0 +1,26 @@
+const Status = ({ statusCode, statusText }) => {
+ return (
+
+ {statusCode}{' '}
+ {statusText || ''}
+
+ )
+}
+
+const statusColor = (statusCode) => {
+ if (statusCode >= 200 && statusCode < 300) {
+ return 'text-green-500';
+ } else if (statusCode >= 300 && statusCode < 400) {
+ return 'text-yellow-500';
+ } else if (statusCode >= 400 && statusCode < 600) {
+ return 'text-red-500';
+ } else {
+ return 'text-gray-500';
+ }
+};
+
+export default Status;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Time/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Time/index.js
new file mode 100644
index 000000000..d4fd0ec07
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Common/Time/index.js
@@ -0,0 +1,36 @@
+import { useState, useEffect } from "react";
+
+const getRelativeTime = (date) => {
+ const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
+ const diff = (date - new Date()) / 1000;
+
+ const timeUnits = [
+ { unit: 'year', seconds: 31536000 },
+ { unit: 'month', seconds: 2592000 },
+ { unit: 'week', seconds: 604800 },
+ { unit: 'day', seconds: 86400 },
+ { unit: 'hour', seconds: 3600 },
+ { unit: 'minute', seconds: 60 },
+ { unit: 'second', seconds: 1 }
+ ];
+
+ for (const { unit, seconds } of timeUnits) {
+ if (Math.abs(diff) >= seconds || unit === 'second') {
+ return rtf.format(Math.round(diff / seconds), unit);
+ }
+ }
+};
+
+export const RelativeTime = ({ timestamp }) => {
+ const [relativeTime, setRelativeTime] = useState(getRelativeTime(new Date(timestamp)));
+
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setRelativeTime(getRelativeTime(new Date(timestamp)));
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [timestamp]);
+
+ return
{relativeTime} ;
+};
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/index.js
new file mode 100644
index 000000000..25d704e63
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Network/index.js
@@ -0,0 +1,54 @@
+const Network = ({ logs }) => {
+ return (
+
+
+ {logs.map((currentLog, index) => {
+ if (index > 0 && currentLog?.type === 'separator') {
+ return
;
+ }
+ const nextLog = logs[index + 1];
+ const isSameLogType = nextLog?.type === currentLog?.type;
+ return <>
+
+ {!isSameLogType &&
}
+ >;
+ })}
+
+
+ )
+}
+
+const NetworkLogsEntry = ({ entry }) => {
+ const { type, message } = entry;
+ let className = '';
+
+ switch (type) {
+ case 'request':
+ className = 'text-blue-500';
+ break;
+ case 'response':
+ className = 'text-green-500';
+ break;
+ case 'error':
+ className = 'text-red-500';
+ break;
+ case 'tls':
+ className = 'text-purple-500';
+ break;
+ case 'info':
+ className = 'text-yellow-500';
+ break;
+ default:
+ className = 'text-gray-400';
+ break;
+ }
+
+ return (
+
+ );
+};
+
+
+export default Network;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js
new file mode 100644
index 000000000..053be2916
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Request/index.js
@@ -0,0 +1,41 @@
+import Headers from "../Common/Headers/index";
+import BodyBlock from "../Common/Body/index";
+
+const safeStringifyJSONIfNotString = (obj) => {
+ if (obj === null || obj === undefined) return '';
+
+ if (typeof obj === 'string') {
+ return obj;
+ }
+
+ try {
+ return JSON.stringify(obj);
+ } catch (e) {
+ return '[Unserializable Object]';
+ }
+};
+
+
+const Request = ({ collection, request, item }) => {
+ let { url, headers, data, dataBuffer, error } = request || {};
+ if (!dataBuffer) {
+ dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
+ }
+
+ return (
+
+ {/* Method and URL */}
+
+
+ {/* Headers */}
+
+
+ {/* Body */}
+
+
+ )
+}
+
+export default Request;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js
new file mode 100644
index 000000000..9a72c8856
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/Response/index.js
@@ -0,0 +1,43 @@
+import BodyBlock from "../Common/Body/index";
+import Headers from "../Common/Headers/index";
+import Status from "../Common/Status/index";
+
+const safeStringifyJSONIfNotString = (obj) => {
+ if (obj === null || obj === undefined) return '';
+
+ if (typeof obj === 'string') {
+ return obj;
+ }
+
+ try {
+ return JSON.stringify(obj);
+ } catch (e) {
+ return '[Unserializable Object]';
+ }
+};
+
+const Response = ({ collection, response, item }) => {
+ let { status, statusCode, statusText, dataBuffer, headers, data, error } = response || {};
+ if (!dataBuffer) {
+ dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
+ }
+
+ return (
+
+ {/* Status */}
+
+
+ {response.duration && {response.duration}ms }
+ {response.size && {response.size}B }
+
+
+ {/* Headers */}
+
+
+ {/* Body */}
+
+
+ )
+}
+
+export default Response;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js
new file mode 100644
index 000000000..c5fc91295
--- /dev/null
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/TimelineItem/index.js
@@ -0,0 +1,85 @@
+import { useState } from "react";
+import Network from "./Network/index";
+import Request from "./Request/index";
+import Response from "./Response/index";
+import Method from "./Common/Method/index";
+import Status from "./Common/Status/index";
+import { RelativeTime } from "./Common/Time/index";
+
+const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2, hideTimestamp = false }) => {
+ const [isCollapsed, _toggleCollapse] = useState(false);
+ const [activeTab, setActiveTab] = useState('request');
+ const toggleCollapse = () => _toggleCollapse(prev => !prev);
+ const { method, status, statusCode, statusText, url = '' } = request || {};
+ const { status: responseStatus, statusCode: responseStatusCode, statusText: responseStatusText } = response || {};
+ const showNetworkLogs = response.timeline && response.timeline.length > 0;
+
+ return (
+
+
+
+
+
+
+
+ {isOauth2 ?
[oauth2.0] : null}
+ {!hideTimestamp && (
+ <>
+
[{new Date(timestamp).toISOString()}]
+
+
+
+ >
+ )}
+
+
+
{url}
+
+ {isCollapsed && (
+ {/* Tabs */}
+
+ setActiveTab('request')}
+ >
+ Request
+
+ setActiveTab('response')}
+ >
+ Response
+
+ {showNetworkLogs && (
+ setActiveTab('networkLogs')}
+ >
+ Network Logs
+
+ )}
+
+
+ {/* Tab Content */}
+
+ {/* Request Tab */}
+ {activeTab === 'request' && (
+
+ )}
+
+ {/* Response Tab */}
+ {activeTab === 'response' && (
+
+ )}
+
+ {/* Network Logs Tab */}
+ {activeTab === 'networkLogs' && showNetworkLogs && (
+
+ )}
+
+
)}
+
+ );
+};
+
+export default TimelineItem;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js
index 9aafb41bb..79bf5725b 100644
--- a/packages/bruno-app/src/components/ResponsePane/Timeline/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/Timeline/index.js
@@ -1,61 +1,148 @@
import React from 'react';
-import forOwn from 'lodash/forOwn';
-import { safeStringifyJSON } from 'utils/common';
import StyledWrapper from './StyledWrapper';
+import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
+import { get } from 'lodash';
+import TimelineItem from './TimelineItem/index';
+import GrpcTimelineItem from './GrpcTimelineItem/index';
-const Timeline = ({ request, response }) => {
- const requestHeaders = [];
- const responseHeaders = typeof response.headers === 'object' ? Object.entries(response.headers) : [];
+const getEffectiveAuthSource = (collection, item) => {
+ const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
+ if (authMode !== 'inherit') return null;
- request = request || {};
- response = response || {};
+ const collectionAuth = get(collection, 'root.request.auth');
+ let effectiveSource = {
+ type: 'collection',
+ uid: collection.uid,
+ auth: collectionAuth
+ };
- forOwn(request.headers, (value, key) => {
- requestHeaders.push({
- name: key,
- value
- });
- });
+ // Get path from collection to item
+ let path = [];
+ let currentItem = findItemInCollection(collection, item?.uid);
+ while (currentItem) {
+ path.unshift(currentItem);
+ currentItem = findParentItemInCollection(collection, currentItem?.uid);
+ }
- let requestData = typeof request?.data === "string" ? request?.data : safeStringifyJSON(request?.data, true);
+ // Check folders in reverse to find the closest auth configuration
+ for (let i of [...path].reverse()) {
+ if (i.type === 'folder') {
+ const folderAuth = get(i, 'root.request.auth');
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
+ effectiveSource = {
+ type: 'folder',
+ uid: i.uid,
+ auth: folderAuth
+ };
+ break;
+ }
+ }
+ }
+
+ return effectiveSource;
+};
+
+const Timeline = ({ collection, item }) => {
+ // Get the effective auth source if auth mode is inherit
+ const authSource = getEffectiveAuthSource(collection, item);
+ const isGrpcRequest = item.type === 'grpc-request';
+
+ // Filter timeline entries based on new rules
+ const combinedTimeline = ([...(collection?.timeline || [])]).filter(obj => {
+ // Always show entries for this item
+ if (obj.itemUid === item.uid) return true;
+
+ // For OAuth2 entries, also show if auth is inherited
+ if (obj.type === 'oauth2' && authSource) {
+ if (authSource.type === 'folder' && obj.folderUid === authSource.uid) return true;
+ if (authSource.type === 'collection' && !obj.folderUid) return true;
+ }
+
+ return false;
+ }).sort((a, b) => b.timestamp - a.timestamp)
return (
-
-
-
- {'>'} {request.method} {request.url}
-
- {requestHeaders.map((h) => {
- return (
-
- {'>'} {h.name}: {h.value}
-
- );
- })}
+
+ {/* Timeline container with scrollbar */}
+
+ {combinedTimeline.map((event, index) => {
+ // Handle regular requests
+ if (event.type === 'request') {
- {requestData ? (
-
- {'>'} data{' '}
- {requestData}
-
- ) : null}
-
-
-
-
- {'<'} {response.status} - {response.statusText}
-
-
- {responseHeaders.map((h) => {
- return (
-
- {'<'} {h[0]}: {h[1]}
-
- );
+ const { data, timestamp, eventType } = event;
+ const { request, response, eventData = {}, timestamp: eventTimestamp = timestamp } = data;
+
+ if (isGrpcRequest) {
+ return (
+
+
+
+ );
+ }
+
+ // Regular HTTP request
+ return (
+
+
+
+ );
+ }
+ // Handle OAuth2 events
+ else if (event.type === 'oauth2') {
+ const { data, timestamp } = event;
+ const { debugInfo } = data;
+ return (
+
+
+
+ {debugInfo && debugInfo.length > 0 ? (
+ debugInfo.map((data, idx) => (
+
+
+
+ ))
+ ) : (
+
No debug information available.
+ )}
+
+
+ );
+ }
+
+ return null;
})}
);
};
-export default Timeline;
+export default Timeline;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/ResponsePane/index.js b/packages/bruno-app/src/components/ResponsePane/index.js
index f0df42e3e..d9147632a 100644
--- a/packages/bruno-app/src/components/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/ResponsePane/index.js
@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
import find from 'lodash/find';
import classnames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -13,15 +13,32 @@ import ResponseSize from './ResponseSize';
import Timeline from './Timeline';
import TestResults from './TestResults';
import TestResultsLabel from './TestResultsLabel';
+import ScriptError from './ScriptError';
+import ScriptErrorIcon from './ScriptErrorIcon';
import StyledWrapper from './StyledWrapper';
import ResponseSave from 'src/components/ResponsePane/ResponseSave';
import ResponseClear from 'src/components/ResponsePane/ResponseClear';
+import SkippedRequest from './SkippedRequest';
+import ClearTimeline from './ClearTimeline/index';
+import ResponseLayoutToggle from './ResponseLayoutToggle';
+import HeightBoundContainer from 'ui/HeightBoundContainer';
-const ResponsePane = ({ rightPaneWidth, item, collection }) => {
+const ResponsePane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isLoading = ['queued', 'sending'].includes(item.requestState);
+ const [showScriptErrorCard, setShowScriptErrorCard] = useState(false);
+
+ const requestTimeline = ([...(collection.timeline || [])]).filter(obj => {
+ if (obj.itemUid === item.uid) return true;
+ });
+
+ useEffect(() => {
+ if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage) {
+ setShowScriptErrorCard(true);
+ }
+ }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage]);
const selectTab = (tab) => {
dispatch(
@@ -33,6 +50,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
};
const response = item.response || {};
+ const responseSize = response.size || 0;
const getTabPanel = (tab) => {
switch (tab) {
@@ -41,7 +59,6 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
{
return ;
}
case 'timeline': {
- return ;
+ return ;
}
case 'tests': {
- return ;
+ return ;
}
default: {
@@ -66,6 +88,14 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
}
};
+ if (item.response && item.status === 'skipped') {
+ return (
+
+
+
+ );
+ }
+
if (isLoading && !item.response) {
return (
@@ -74,11 +104,11 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
);
}
- if (!item.response) {
+ if (!item.response && !requestTimeline?.length) {
return (
-
+
-
+
);
}
@@ -99,9 +129,11 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const responseHeadersCount = typeof response.headers === 'object' ? Object.entries(response.headers).length : 0;
+ const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage;
+
return (
-
+
selectTab('response')}>
Response
@@ -113,23 +145,62 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
Timeline
selectTab('tests')}>
-
+
{!isLoading ? (
-
-
-
-
-
+ {hasScriptError && !showScriptErrorCard && (
+ setShowScriptErrorCard(true)}
+ />
+ )}
+
+ {focusedTab?.responsePaneTab === "timeline" ? (
+
+ ) : (item?.response && !item?.response?.error) ? (
+ <>
+
+
+
+
+
+ >
+ ) : null}
) : null}
{isLoading ? : null}
- {getTabPanel(focusedTab.responsePaneTab)}
+ {hasScriptError && showScriptErrorCard && (
+ setShowScriptErrorCard(false)}
+ />
+ )}
+
+ {!item?.response ? (
+ focusedTab?.responsePaneTab === "timeline" && requestTimeline?.length ? (
+
+ ) : null
+ ) : (
+ <>{getTabPanel(focusedTab.responsePaneTab)}>
+ )}
+
);
diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js
index 0b49d66ca..aa91e576c 100644
--- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/StyledWrapper.js
@@ -33,6 +33,10 @@ const StyledWrapper = styled.div`
.all-tests-passed {
color: ${(props) => props.theme.colors.text.green} !important;
}
+
+ .skipped-request {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
`;
export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
index 8fd8de9d9..609515c8c 100644
--- a/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
+++ b/packages/bruno-app/src/components/RunnerResults/ResponsePane/index.js
@@ -1,27 +1,38 @@
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
import get from 'lodash/get';
import classnames from 'classnames';
-import { safeStringifyJSON } from 'utils/common';
import QueryResult from 'components/ResponsePane/QueryResult';
import ResponseHeaders from 'components/ResponsePane/ResponseHeaders';
import StatusCode from 'components/ResponsePane/StatusCode';
import ResponseTime from 'components/ResponsePane/ResponseTime';
import ResponseSize from 'components/ResponsePane/ResponseSize';
-import Timeline from 'components/ResponsePane/Timeline';
import TestResults from 'components/ResponsePane/TestResults';
import TestResultsLabel from 'components/ResponsePane/TestResultsLabel';
import StyledWrapper from './StyledWrapper';
+import SkippedRequest from 'components/ResponsePane/SkippedRequest';
+import RunnerTimeline from 'components/ResponsePane/RunnerTimeline';
+import ScriptError from 'components/ResponsePane/ScriptError';
+import ScriptErrorIcon from 'components/ResponsePane/ScriptErrorIcon';
const ResponsePane = ({ rightPaneWidth, item, collection }) => {
const [selectedTab, setSelectedTab] = useState('response');
+ const [showScriptErrorCard, setShowScriptErrorCard] = useState(false);
- const { requestSent, responseReceived, testResults, assertionResults, error } = item;
+ const { requestSent, responseReceived, testResults, assertionResults, preRequestTestResults, postResponseTestResults, error } = item;
+
+ useEffect(() => {
+ if (item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage) {
+ setShowScriptErrorCard(true);
+ }
+ }, [item?.preRequestScriptErrorMessage, item?.postResponseScriptErrorMessage, item?.testScriptErrorMessage]);
const headers = get(item, 'responseReceived.headers', []);
const status = get(item, 'responseReceived.status', 0);
const size = get(item, 'responseReceived.size', 0);
const duration = get(item, 'responseReceived.duration', 0);
+ const hasScriptError = item?.preRequestScriptErrorMessage || item?.postResponseScriptErrorMessage || item?.testScriptErrorMessage;
+
const selectTab = (tab) => setSelectedTab(tab);
const getTabPanel = (tab) => {
@@ -45,10 +56,22 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
return ;
}
case 'timeline': {
- return ;
+ return (
+
+ );
}
case 'tests': {
- return ;
+ return ;
}
default: {
@@ -63,9 +86,17 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
});
};
+ if (item.status === 'skipped') {
+ return (
+
+
+
+ );
+ }
+
return (
-
-
+
+
selectTab('response')}>
Response
@@ -77,15 +108,36 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
Timeline
selectTab('tests')}>
-
+
+ {hasScriptError && !showScriptErrorCard && (
+ setShowScriptErrorCard(true)}
+ />
+ )}
- {getTabPanel(selectedTab)}
+
+ {hasScriptError && showScriptErrorCard && (
+ setShowScriptErrorCard(false)}
+ />
+ )}
+
+ {getTabPanel(selectedTab)}
+
+
);
};
diff --git a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/StyledWrapper.jsx b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/StyledWrapper.jsx
new file mode 100644
index 000000000..0a6dbeea2
--- /dev/null
+++ b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/StyledWrapper.jsx
@@ -0,0 +1,231 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ background-color: ${props => props.theme.sidebar.bg};
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ overflow: hidden;
+
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem;
+ border-bottom: 1px solid ${props => props.theme.sidebar.dragbar};
+ margin-bottom: 0.5rem;
+
+ .counter {
+ font-size: 0.875rem;
+ font-weight: 500;
+ }
+
+ .actions {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ }
+
+ .btn-select-all,
+ .btn-reset {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.75rem;
+ color: ${props => props.theme.textLink};
+ background: none;
+ border: none;
+ padding: 0.25rem 0.5rem;
+ cursor: pointer;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .request-list {
+ flex: 1;
+ overflow-y: auto;
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background-color: ${props => props.theme.console.scrollbarThumb};
+ border-radius: 3px;
+ }
+
+ .loading-message,
+ .empty-message {
+ padding: 0.75rem;
+ color: ${props => props.theme.colors.text.muted};
+ font-size: 0.875rem;
+ }
+
+ .requests-container {
+ padding: 0.5rem;
+ position: relative;
+ }
+ }
+
+ .request-item {
+ display: flex;
+ align-items: center;
+ padding: 0.5rem;
+ border-radius: 4px;
+ margin-bottom: 0.25rem;
+ position: relative;
+ height: 2.5rem;
+ border: 1px solid transparent;
+ background-color: ${props => props.theme.sidebar.bg};
+ transition: transform 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
+
+ &.is-selected {
+ background-color: ${props => props.theme.requestTabs.active.bg};
+ }
+
+ &.is-dragging {
+ opacity: 0.5;
+ background-color: ${props => props.theme.sidebar.bg};
+ border: 1px dashed ${props => props.theme.sidebar.dragbar};
+ transform: scale(0.98);
+ box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
+ z-index: 5;
+ }
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: ${props => props.theme.dragAndDrop?.border || props.theme.textLink};
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease;
+ }
+
+ &::before {
+ top: -1px;
+ }
+
+ &::after {
+ bottom: -1px;
+ }
+
+ &.drop-target-above {
+ &::before {
+ opacity: 1;
+ height: 2px;
+ background: ${props => props.theme.dragAndDrop?.border || props.theme.textLink};
+ }
+ }
+
+ &.drop-target-below {
+ &::after {
+ opacity: 1;
+ height: 2px;
+ background: ${props => props.theme.dragAndDrop?.border || props.theme.textLink};
+ }
+ }
+
+ .drag-handle {
+ cursor: grab;
+ margin-right: 0.25rem;
+ color: ${props => props.theme.sidebar.muted};
+ display: flex;
+ align-items: center;
+ transition: color 0.15s ease;
+
+ &:hover {
+ color: ${props => props.theme.text};
+ }
+
+ &:active {
+ cursor: grabbing;
+ color: ${props => props.theme.textLink};
+ }
+ }
+
+ .checkbox-container {
+ cursor: pointer;
+ margin-right: 0.5rem;
+
+ .checkbox {
+ width: 1rem;
+ height: 1rem;
+ border: 1px solid ${props => props.theme.sidebar.dragbar};
+ border-radius: 3px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.1s ease;
+
+ &:hover {
+ border-color: ${props => props.theme.textLink};
+ }
+ }
+ }
+
+ .method {
+ font-family: monospace;
+ font-size: 0.75rem;
+ font-weight: 500;
+ margin-right: 0.5rem;
+ min-width: 3rem;
+ color: ${props => props.theme.sidebar.muted}; // Default color for unknown methods
+
+ &.method-get {
+ color: ${props => props.theme.request.methods.get};
+ }
+
+ &.method-post {
+ color: ${props => props.theme.request.methods.post};
+ }
+
+ &.method-put {
+ color: ${props => props.theme.request.methods.put};
+ }
+
+ &.method-delete {
+ color: ${props => props.theme.request.methods.delete};
+ }
+
+ &.method-patch {
+ color: ${props => props.theme.request.methods.patch};
+ }
+
+ &.method-options {
+ color: ${props => props.theme.request.methods.options};
+ }
+
+ &.method-head {
+ color: ${props => props.theme.request.methods.head};
+ }
+ }
+
+ .request-name {
+ flex: 1;
+ font-size: 0.875rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ .folder-path {
+ margin-left: 0.5rem;
+ font-size: 0.75rem;
+ color: ${props => props.theme.sidebar.muted};
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx
new file mode 100644
index 000000000..aa3a3c9f0
--- /dev/null
+++ b/packages/bruno-app/src/components/RunnerResults/RunConfigurationPanel/index.jsx
@@ -0,0 +1,327 @@
+import React, { useEffect, useState, useCallback, useRef } from 'react';
+import { useDrag, useDrop } from 'react-dnd';
+import { getEmptyImage } from 'react-dnd-html5-backend';
+import { IconGripVertical, IconCheck, IconAdjustmentsAlt } from '@tabler/icons';
+import { useDispatch } from 'react-redux';
+import { updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions';
+import StyledWrapper from './StyledWrapper';
+import { isItemARequest } from 'utils/collections';
+import path from 'utils/common/path';
+import { cloneDeep, get } from 'lodash';
+
+const ItemTypes = {
+ REQUEST_ITEM: 'request-item'
+};
+
+const RequestItem = ({ item, index, moveItem, isSelected, onSelect, onDrop }) => {
+ const ref = useRef(null);
+ const [dropType, setDropType] = useState(null);
+
+ const determineDropType = (monitor) => {
+ const hoverBoundingRect = ref.current?.getBoundingClientRect();
+ const clientOffset = monitor.getClientOffset();
+ if (!hoverBoundingRect || !clientOffset) return null;
+
+ const clientY = clientOffset.y - hoverBoundingRect.top;
+ const middleY = hoverBoundingRect.height / 2;
+
+ return clientY < middleY ? 'above' : 'below';
+ };
+
+ const [{ isDragging }, drag, preview] = useDrag({
+ type: ItemTypes.REQUEST_ITEM,
+ item: { uid: item.uid, name: item.name, request: item.request, index },
+ collect: (monitor) => ({ isDragging: monitor.isDragging() }),
+ options: {
+ dropEffect: "move"
+ },
+ end: (draggedItem, monitor) => {
+ if (monitor.didDrop()) {
+ onDrop();
+ }
+ },
+ });
+
+ const [{ isOver, canDrop }, drop] = useDrop({
+ accept: ItemTypes.REQUEST_ITEM,
+ hover: (draggedItem, monitor) => {
+ if (draggedItem.uid === item.uid) {
+ setDropType(null);
+ return;
+ }
+
+ const dropType = determineDropType(monitor);
+ setDropType(dropType);
+ },
+ drop: (draggedItem, monitor) => {
+ if (draggedItem.uid === item.uid) return;
+
+ const dropType = determineDropType(monitor);
+ let targetIndex = index;
+
+ if (dropType === 'below') {
+ targetIndex = index + 1;
+ }
+
+ if (draggedItem.index < targetIndex) {
+ targetIndex = targetIndex - 1;
+ }
+
+ moveItem(draggedItem.uid, targetIndex);
+ setDropType(null);
+ return { item: draggedItem };
+ },
+ collect: (monitor) => ({
+ isOver: monitor.isOver(),
+ canDrop: monitor.canDrop()
+ }),
+ });
+
+ useEffect(() => {
+ preview(getEmptyImage(), { captureDraggingState: true });
+ }, []);
+
+ // Clear drop type when not hovering
+ useEffect(() => {
+ if (!isOver) {
+ setDropType(null);
+ }
+ }, [isOver]);
+
+ drag(drop(ref));
+
+ const itemClasses = [
+ 'request-item',
+ isDragging ? 'is-dragging' : '',
+ isSelected ? 'is-selected' : '',
+ isOver && canDrop && dropType === 'above' ? 'drop-target-above' : '',
+ isOver && canDrop && dropType === 'below' ? 'drop-target-below' : ''
+ ].filter(Boolean).join(' ');
+
+ return (
+
+
+
+
+
+
onSelect(item)}>
+
+ {isSelected && }
+
+
+
+
+ {item.request?.method.toUpperCase()}
+
+
+
+ {item.name}
+ {item.folderPath && (
+ {item.folderPath}
+ )}
+
+
+ );
+};
+
+const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems }) => {
+ const dispatch = useDispatch();
+ const [flattenedRequests, setFlattenedRequests] = useState([]);
+ const [originalRequests, setOriginalRequests] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const flattenRequests = useCallback((collection) => {
+ const result = [];
+
+ const processItems = (items) => {
+ if (!items?.length) return;
+
+ items.forEach(item => {
+ if (isItemARequest(item) && !item.partial) {
+ const relativePath = path.relative(collection.pathname, path.dirname(item.pathname));
+ const folderPath = relativePath !== '.' ? relativePath : '';
+
+ result.push({
+ ...item,
+ folderPath: folderPath.replace(/\\/g, '/')
+ });
+ }
+
+ if (item.items?.length) {
+ processItems(item.items);
+ }
+ });
+ };
+
+ processItems(collection.items);
+ return result;
+ }, []);
+
+ useEffect(() => {
+ setIsLoading(true);
+
+ try {
+ const structureCopy = cloneDeep(collection);
+ const requests = flattenRequests(structureCopy);
+
+ const savedConfiguration = get(collection, 'runnerConfiguration', null);
+ if (savedConfiguration?.requestItemsOrder?.length > 0) {
+ const orderedRequests = [];
+ const requestMap = new Map(requests.map(req => [req.uid, req]));
+
+ savedConfiguration.requestItemsOrder.forEach(uid => {
+ const request = requestMap.get(uid);
+ if (request) {
+ orderedRequests.push(request);
+ requestMap.delete(uid);
+ }
+ });
+
+ requestMap.forEach(request => {
+ orderedRequests.push(request);
+ });
+
+ setFlattenedRequests(orderedRequests);
+ } else {
+ setFlattenedRequests(requests);
+ }
+
+ setOriginalRequests(cloneDeep(requests));
+ } catch (error) {
+ console.error("Error loading collection structure:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [collection, flattenRequests]);
+
+ const moveItem = useCallback((draggedItemUid, hoverIndex) => {
+ setFlattenedRequests((prevRequests) => {
+ const dragIndex = prevRequests.findIndex(item => item.uid === draggedItemUid);
+
+ if (dragIndex === -1 || dragIndex === hoverIndex) {
+ return prevRequests;
+ }
+
+ const updatedRequests = [...prevRequests];
+ const [draggedItem] = updatedRequests.splice(dragIndex, 1);
+ updatedRequests.splice(hoverIndex, 0, draggedItem);
+
+ return updatedRequests;
+ });
+ }, []);
+
+ const handleDrop = useCallback(() => {
+ const selectedUids = new Set(selectedItems);
+
+ setFlattenedRequests(currentRequests => {
+ const newOrderedSelectedUids = currentRequests
+ .filter(item => selectedUids.has(item.uid))
+ .map(item => item.uid);
+
+ const allRequestUidsOrder = currentRequests.map(item => item.uid);
+
+ setSelectedItems(newOrderedSelectedUids);
+ dispatch(updateRunnerConfiguration(collection.uid, newOrderedSelectedUids, allRequestUidsOrder));
+
+ return currentRequests;
+ });
+ }, [selectedItems, collection.uid, dispatch, setSelectedItems]);
+
+ const handleRequestSelect = useCallback((item) => {
+ try {
+ if (selectedItems.includes(item.uid)) {
+ const newSelectedUids = selectedItems.filter(uid => uid !== item.uid);
+ setSelectedItems(newSelectedUids);
+
+ const allRequestUidsOrder = flattenedRequests.map(item => item.uid);
+ dispatch(updateRunnerConfiguration(collection.uid, newSelectedUids, allRequestUidsOrder));
+ } else {
+ const newSelectedUids = [...selectedItems, item.uid];
+
+ const orderedSelectedUids = flattenedRequests
+ .filter(req => newSelectedUids.includes(req.uid))
+ .map(req => req.uid);
+
+ setSelectedItems(orderedSelectedUids);
+
+ const allRequestUidsOrder = flattenedRequests.map(item => item.uid);
+ dispatch(updateRunnerConfiguration(collection.uid, orderedSelectedUids, allRequestUidsOrder));
+ }
+ } catch (error) {
+ console.error("Error selecting item:", error);
+ }
+ }, [selectedItems, setSelectedItems, flattenedRequests, dispatch, collection.uid]);
+
+ const handleSelectAll = useCallback(() => {
+ try {
+ const allRequestUidsOrder = flattenedRequests.map(item => item.uid);
+
+ if (selectedItems.length === flattenedRequests.length) {
+ setSelectedItems([]);
+ dispatch(updateRunnerConfiguration(collection.uid, [], allRequestUidsOrder));
+ } else {
+ setSelectedItems(allRequestUidsOrder);
+ dispatch(updateRunnerConfiguration(collection.uid, allRequestUidsOrder, allRequestUidsOrder));
+ }
+ } catch (error) {
+ console.error("Error selecting/deselecting all items:", error);
+ }
+ }, [flattenedRequests, selectedItems, setSelectedItems, dispatch, collection.uid]);
+
+ const handleReset = useCallback(() => {
+ try {
+ setFlattenedRequests(cloneDeep(originalRequests));
+ setSelectedItems([]);
+ dispatch(updateRunnerConfiguration(collection.uid, [], []));
+ } catch (error) {
+ console.error("Error resetting configuration:", error);
+ }
+ }, [originalRequests, setSelectedItems, collection.uid, dispatch]);
+
+ return (
+
+
+
+ {selectedItems.length} of {flattenedRequests.length} selected
+
+
+
+ {selectedItems.length === flattenedRequests.length ? "Deselect All" : "Select All"}
+
+
+
+ Reset
+
+
+
+
+
+ {isLoading ? (
+
Loading requests...
+ ) : flattenedRequests.length === 0 ? (
+
No requests found in this collection
+ ) : (
+
+ {flattenedRequests.map((item, idx) => {
+ const isSelected = selectedItems.includes(item.uid);
+
+ return (
+ handleRequestSelect(item)}
+ moveItem={moveItem}
+ onDrop={handleDrop}
+ />
+ );
+ })}
+
+ )}
+
+
+ );
+};
+
+export default RunConfigurationPanel;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx b/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx
new file mode 100644
index 000000000..b61b5b9f6
--- /dev/null
+++ b/packages/bruno-app/src/components/RunnerResults/RunnerTags/index.jsx
@@ -0,0 +1,130 @@
+import React, { useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { get, cloneDeep, find } from 'lodash';
+import { updateCollectionTagsList, updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections';
+import TagList from 'components/TagList';
+
+const RunnerTags = ({ collectionUid, className = '' }) => {
+ const dispatch = useDispatch();
+ const collections = useSelector((state) => state.collections.collections);
+ const collection = cloneDeep(find(collections, (c) => c.uid === collectionUid));
+
+ // tags for the collection run
+ const tags = get(collection, 'runnerTags', { include: [], exclude: [] });
+
+ // have tags been enabled for the collection run
+ const tagsEnabled = get(collection, 'runnerTagsEnabled', false);
+
+ // all available tags in the collection that can be used for filtering
+ const availableTags = get(collection, 'allTags', []);
+ const tagsHintList = availableTags.filter(t => !tags.exclude.includes(t) && !tags.include.includes(t));
+
+ useEffect(() => {
+ dispatch(updateCollectionTagsList({ collectionUid }));
+ }, [collection.uid, dispatch]);
+
+ const handleValidation = (tag) => {
+ const trimmedTag = tag.trim();
+ if (!availableTags.includes(trimmedTag)) {
+ return 'tag does not exist!';
+ }
+ if (tags.include.includes(trimmedTag)) {
+ return 'tag already present in the include list!';
+ }
+ if (tags.exclude.includes(trimmedTag)) {
+ return 'tag is present in the exclude list!';
+ }
+ }
+
+ const handleAddTag = ({ tag, to }) => {
+ const trimmedTag = tag.trim();
+ if (!trimmedTag) return;
+ // add tag to the `include` list
+ if (to === 'include') {
+ if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return;
+ if (!availableTags.includes(trimmedTag)) {
+ return;
+ }
+ const newTags = { ...tags, include: [...tags.include, trimmedTag].sort() };
+ setTags(newTags);
+ return;
+ }
+ // add tag to the `exclude` list
+ if (to === 'exclude') {
+ if (tags.include.includes(trimmedTag) || tags.exclude.includes(trimmedTag)) return;
+ if (!availableTags.includes(trimmedTag)) {
+ return;
+ }
+ const newTags = { ...tags, exclude: [...tags.exclude, trimmedTag].sort() };
+ setTags(newTags);
+ }
+ };
+
+ const handleRemoveTag = ({ tag, from }) => {
+ const trimmedTag = tag.trim();
+ if (!trimmedTag) return;
+ // remove tag from the `include` list
+ if (from === 'include') {
+ if (!tags.include.includes(trimmedTag)) return;
+ const newTags = { ...tags, include: tags.include.filter((t) => t !== trimmedTag) };
+ setTags(newTags);
+ return;
+ }
+ // remove tag from the `exclude` list
+ if (from === 'exclude') {
+ if (!tags.exclude.includes(trimmedTag)) return;
+ const newTags = { ...tags, exclude: tags.exclude.filter((t) => t !== trimmedTag) };
+ setTags(newTags);
+ }
+ };
+
+ const setTags = (tags) => {
+ dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tags }));
+ };
+
+ const setTagsEnabled = (tagsEnabled) => {
+ dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled }));
+ };
+
+ return (
+
+
+ setTagsEnabled(!tagsEnabled)}
+ />
+ Filter requests with tags
+
+ {tagsEnabled && (
+
+
+ Included tags:
+ handleAddTag({ tag, to: 'include' })}
+ handleRemoveTag={tag => handleRemoveTag({ tag, from: 'include' })}
+ tagsHintList={tagsHintList}
+ handleValidation={handleValidation}
+ />
+
+
+ Excluded tags:
+ handleAddTag({ tag, to: 'exclude' })}
+ handleRemoveTag={tag => handleRemoveTag({ tag, from: 'exclude' })}
+ tagsHintList={tagsHintList}
+ handleValidation={handleValidation}
+ />
+
+
+ )}
+
+ )
+}
+
+export default RunnerTags;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js b/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js
index 38dd7511e..b3fbaaebd 100644
--- a/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RunnerResults/StyledWrapper.js
@@ -39,6 +39,10 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
}
+
+ .skipped-request {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/RunnerResults/index.jsx b/packages/bruno-app/src/components/RunnerResults/index.jsx
index f7c1e4d9c..7956eb1ad 100644
--- a/packages/bruno-app/src/components/RunnerResults/index.jsx
+++ b/packages/bruno-app/src/components/RunnerResults/index.jsx
@@ -1,29 +1,53 @@
import React, { useState, useRef, useEffect } from 'react';
-import path from 'path';
+import path from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { get, cloneDeep } from 'lodash';
-import { runCollectionFolder, cancelRunnerExecution } from 'providers/ReduxStore/slices/collections/actions';
+import { runCollectionFolder, cancelRunnerExecution, mountCollection, updateRunnerConfiguration } from 'providers/ReduxStore/slices/collections/actions';
import { resetCollectionRunner } from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, getTotalRequestCountInCollection } from 'utils/collections';
-import { IconRefresh, IconCircleCheck, IconCircleX, IconCheck, IconX, IconRun } from '@tabler/icons';
-import slash from 'utils/common/slash';
+import { IconRefresh, IconCircleCheck, IconCircleX, IconCircleOff, IconCheck, IconX, IconRun, IconLoader2 } from '@tabler/icons';
import ResponsePane from './ResponsePane';
import StyledWrapper from './StyledWrapper';
+import { areItemsLoading } from 'utils/collections';
+import RunnerTags from './RunnerTags/index';
+import RunConfigurationPanel from './RunConfigurationPanel';
+import { getRequestItemsForCollectionRun } from 'utils/collections/index';
+import { updateRunnerTagsDetails } from 'providers/ReduxStore/slices/collections/index';
-const getRelativePath = (fullPath, pathname) => {
- // convert to unix style path
- fullPath = slash(fullPath);
- pathname = slash(pathname);
-
+const getDisplayName = (fullPath, pathname, name = '') => {
let relativePath = path.relative(fullPath, pathname);
- const { dir, name } = path.parse(relativePath);
+ const { dir = '' } = path.parse(relativePath);
return path.join(dir, name);
};
+const getTestStatus = (results) => {
+ if (!results || !results.length) return 'pass';
+ const failed = results.filter((result) => result.status === 'fail');
+ return failed.length ? 'fail' : 'pass';
+};
+
+const allTestsPassed = (item) => {
+ return item.status !== 'error' &&
+ item.testStatus === 'pass' &&
+ item.assertionStatus === 'pass' &&
+ item.preRequestTestStatus === 'pass' &&
+ item.postResponseTestStatus === 'pass';
+};
+
+const anyTestFailed = (item) => {
+ return item.status === 'error' ||
+ item.testStatus === 'fail' ||
+ item.assertionStatus === 'fail' ||
+ item.preRequestTestStatus === 'fail' ||
+ item.postResponseTestStatus === 'fail';
+};
+
export default function RunnerResults({ collection }) {
const dispatch = useDispatch();
const [selectedItem, setSelectedItem] = useState(null);
const [delay, setDelay] = useState(null);
+ const [selectedRequestItems, setSelectedRequestItems] = useState([]);
+ const [configureMode, setConfigureMode] = useState(false);
// ref for the runner output body
const runnerBodyRef = useRef();
@@ -42,9 +66,41 @@ export default function RunnerResults({ collection }) {
autoScrollRunnerBody();
}, [collection, setSelectedItem]);
+ useEffect(() => {
+ const runnerInfo = get(collection, 'runnerResult.info', {});
+ if (runnerInfo.status === 'running') {
+ setConfigureMode(false);
+ }
+ }, [collection.runnerResult]);
+
+ useEffect(() => {
+ const savedConfiguration = get(collection, 'runnerConfiguration', null);
+ if (savedConfiguration) {
+ if (savedConfiguration.selectedRequestItems && configureMode) {
+ setSelectedRequestItems(savedConfiguration.selectedRequestItems);
+ }
+ if (savedConfiguration.delay !== undefined && delay === null) {
+ setDelay(savedConfiguration.delay);
+ }
+ }
+ }, [collection.runnerConfiguration, configureMode, delay]);
+
const collectionCopy = cloneDeep(collection);
const runnerInfo = get(collection, 'runnerResult.info', {});
+ // tags for the collection run
+ const tags = get(collection, 'runnerTags', { include: [], exclude: [] });
+
+ // have tags been enabled for the collection run
+ const tagsEnabled = get(collection, 'runnerTagsEnabled', false);
+
+ // have tags been added for the collection run
+ const areTagsAdded = tags.include.length > 0 || tags.exclude.length > 0;
+
+ const requestItemsForCollectionRun = getRequestItemsForCollectionRun({ recursive: true, tags, items: collection.items });
+ const totalRequestItemsCountForCollectionRun = requestItemsForCollectionRun.length;
+ const shouldDisableCollectionRun = totalRequestItemsCountForCollectionRun <= 0;
+
const items = cloneDeep(get(collection, 'runnerResult.items', []))
.map((item) => {
const info = findItemInCollection(collectionCopy, item.uid);
@@ -57,33 +113,56 @@ export default function RunnerResults({ collection }) {
type: info.type,
filename: info.filename,
pathname: info.pathname,
- relativePath: getRelativePath(collection.pathname, info.pathname)
+ displayName: getDisplayName(collection.pathname, info.pathname, info.name),
+ tags: [...(info.request?.tags || [])].sort(),
};
- if (newItem.status !== 'error' && newItem.status !== 'skipped') {
- if (newItem.testResults) {
- const failed = newItem.testResults.filter((result) => result.status === 'fail');
- newItem.testStatus = failed.length ? 'fail' : 'pass';
- } else {
- newItem.testStatus = 'pass';
- }
-
- if (newItem.assertionResults) {
- const failed = newItem.assertionResults.filter((result) => result.status === 'fail');
- newItem.assertionStatus = failed.length ? 'fail' : 'pass';
- } else {
- newItem.assertionStatus = 'pass';
- }
+ if (newItem.status !== 'error' && newItem.status !== 'skipped' && newItem.status !== 'running') {
+ newItem.testStatus = getTestStatus(newItem.testResults);
+ newItem.assertionStatus = getTestStatus(newItem.assertionResults);
+ newItem.preRequestTestStatus = getTestStatus(newItem.preRequestTestResults);
+ newItem.postResponseTestStatus = getTestStatus(newItem.postResponseTestResults);
}
return newItem;
})
.filter(Boolean);
+ const ensureCollectionIsMounted = () => {
+ if(collection.mountStatus === 'mounted'){
+ return;
+ }
+ dispatch(mountCollection({
+ collectionUid: collection.uid,
+ collectionPathname: collection.pathname,
+ brunoConfig: collection.brunoConfig
+ }));
+ };
+
const runCollection = () => {
- dispatch(runCollectionFolder(collection.uid, null, true, Number(delay)));
+ if (configureMode && selectedRequestItems.length > 0) {
+ dispatch(updateRunnerConfiguration(collection.uid, selectedRequestItems, selectedRequestItems, delay));
+ dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags, selectedRequestItems));
+ } else {
+ dispatch(updateRunnerConfiguration(collection.uid, [], [], delay));
+ dispatch(runCollectionFolder(collection.uid, null, true, Number(delay), tagsEnabled && tags));
+ }
};
const runAgain = () => {
- dispatch(runCollectionFolder(collection.uid, runnerInfo.folderUid, runnerInfo.isRecursive, Number(delay)));
+ ensureCollectionIsMounted();
+ // Get the saved configuration to determine what to run
+ const savedConfiguration = get(collection, 'runnerConfiguration', null);
+ const savedSelectedItems = savedConfiguration?.selectedRequestItems || [];
+ const savedDelay = savedConfiguration?.delay !== undefined ? savedConfiguration.delay : delay;
+ dispatch(
+ runCollectionFolder(
+ collection.uid,
+ runnerInfo.folderUid,
+ true,
+ Number(savedDelay),
+ tagsEnabled && tags,
+ savedSelectedItems
+ )
+ );
};
const resetRunner = () => {
@@ -92,118 +171,207 @@ export default function RunnerResults({ collection }) {
collectionUid: collection.uid
})
);
+ setSelectedRequestItems([]);
+ setConfigureMode(false);
+ setDelay(null);
};
const cancelExecution = () => {
dispatch(cancelRunnerExecution(runnerInfo.cancelTokenUid));
};
+ const toggleConfigureMode = () => {
+ dispatch(updateRunnerTagsDetails({ collectionUid: collection.uid, tagsEnabled: false }));
+ setConfigureMode(!configureMode);
+ };
+
+ useEffect(() => {
+ if(tagsEnabled) {
+ setConfigureMode(false);
+ }
+ }, [tagsEnabled]);
+
const totalRequestsInCollection = getTotalRequestCountInCollection(collectionCopy);
- const passedRequests = items.filter((item) => {
- return item.status !== 'error' && item.testStatus === 'pass' && item.assertionStatus === 'pass';
- });
- const failedRequests = items.filter((item) => {
- return (item.status !== 'error' && item.testStatus === 'fail') || item.assertionStatus === 'fail';
+ const passedRequests = items.filter(allTestsPassed);
+ const failedRequests = items.filter(anyTestFailed);
+
+ const skippedRequests = items.filter((item) => {
+ return item.status === 'skipped';
});
+ let isCollectionLoading = areItemsLoading(collection);
if (!items || !items.length) {
return (
-
-
- Runner
-
-
-
- You have {totalRequestsInCollection} requests in this collection.
-
+
+
+
+
+ Runner
+
+
+
+ You have {totalRequestsInCollection} requests in this collection.
+ {isCollectionLoading && (
+
+ (Loading...)
+
+ )}
+
+ {isCollectionLoading ?
Requests in this collection are still loading.
: null}
+
+ Delay (in ms)
+ setDelay(e.target.value)}
+ />
+
-
-
Delay (in ms)
-
setDelay(e.target.value)}
- />
+ {/* Tags for the collection run */}
+
+
+ {/* Configure requests option */}
+
+
+
+ Configure requests to run
+
+
+
+
+
+ {configureMode && selectedRequestItems.length > 0
+ ? `Run ${selectedRequestItems.length} Selected Request${selectedRequestItems.length > 1 ? 's' : ''}`
+ : "Run Collection"
+ }
+
+
+
+ Reset
+
+
+
+
+ {configureMode && (
+
+
+
+ )}
-
-
- Run Collection
-
-
-
- Reset
-
);
}
return (
-
-
-
+
+
+
Runner
{runnerInfo.status !== 'ended' && runnerInfo.cancelTokenUid && (
-
+
Cancel Execution
)}
-
+
+
- Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}
+ Total Requests: {items.length}, Passed: {passedRequests.length}, Failed: {failedRequests.length}, Skipped:{' '}
+ {skippedRequests.length}
- {runnerInfo?.statusText ?
+ {tagsEnabled && areTagsAdded && (
+
+ Tags:
+
+
+ {tags.include.join(', ')}
+
+
+ {tags.exclude.join(', ')}
+
+
+
+ )}
+ {runnerInfo?.statusText ?
{runnerInfo?.statusText}
- : null}
- {items.map((item) => {
- return (
-
-
-
-
- {item.status !== 'error' && item.testStatus === 'pass' && item.status !== 'skipped' ? (
-
- ) : (
-
- )}
-
-
- {item.relativePath}
-
- {item.status !== 'error' && item.status !== 'skipped' && item.status !== 'completed' ? (
-
- ) : item.responseReceived?.status ? (
- setSelectedItem(item)}>
- {item.responseReceived?.status}
- -
- {item.responseReceived?.statusText}
-
- ) : (
- setSelectedItem(item)}>
- (request failed)
-
- )}
-
- {item.status == 'error' ?
{item.error}
: null}
+ : null}
-
+
{runnerInfo.status === 'ended' ? (
Run Again
-
+
Run Collection
@@ -260,19 +470,22 @@ export default function RunnerResults({ collection }) {
) : null}
{selectedItem ? (
-
-
-
-
{selectedItem.relativePath}
+
+
+
+ {selectedItem.displayName}
- {selectedItem.testStatus === 'pass' ? (
+ {allTestsPassed(selectedItem) ?
- ) : (
+ : null}
+ {anyTestFailed(selectedItem) ?
- )}
+ : null}
+ {selectedItem.status === 'skipped' ?
+
+ : null}
- {/*
{selectedItem.relativePath}
*/}
diff --git a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js
index ecaab4ff1..81cd5cd08 100644
--- a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js
+++ b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/StyledWrapper.js
@@ -3,16 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
- span.beta-tag {
- display: flex;
- align-items: center;
- padding: 0.1rem 0.25rem;
- font-size: 0.75rem;
- border-radius: 0.25rem;
- color: ${(props) => props.theme.colors.text.green};
- border: solid 1px ${(props) => props.theme.colors.text.green} !important;
- }
-
span.developer-mode-warning {
font-weight: 400;
color: ${(props) => props.theme.colors.text.yellow};
diff --git a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js
index 52a988ea7..4cbbc80ea 100644
--- a/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js
+++ b/packages/bruno-app/src/components/SecuritySettings/JsSandboxModeModal/index.js
@@ -61,7 +61,6 @@ const JsSandboxModeModal = ({ collection }) => {
Safe Mode
-
BETA
JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.
@@ -79,15 +78,12 @@ const JsSandboxModeModal = ({ collection }) => {
/>
Developer Mode
- (use only if you trust the collections authors)
+ (use only if you trust the authors of the collection)
JavaScript code has access to the filesystem, can execute system commands and access sensitive information.
-
- * SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github.
-
diff --git a/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js b/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js
index ecaab4ff1..81cd5cd08 100644
--- a/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js
+++ b/packages/bruno-app/src/components/SecuritySettings/StyledWrapper.js
@@ -3,16 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
- span.beta-tag {
- display: flex;
- align-items: center;
- padding: 0.1rem 0.25rem;
- font-size: 0.75rem;
- border-radius: 0.25rem;
- color: ${(props) => props.theme.colors.text.green};
- border: solid 1px ${(props) => props.theme.colors.text.green} !important;
- }
-
span.developer-mode-warning {
font-weight: 400;
color: ${(props) => props.theme.colors.text.yellow};
diff --git a/packages/bruno-app/src/components/SecuritySettings/index.js b/packages/bruno-app/src/components/SecuritySettings/index.js
index 7761760f6..f7738cfa8 100644
--- a/packages/bruno-app/src/components/SecuritySettings/index.js
+++ b/packages/bruno-app/src/components/SecuritySettings/index.js
@@ -47,7 +47,6 @@ const SecuritySettings = ({ collection }) => {
Safe Mode
-
BETA
JavaScript code is executed in a secure sandbox and cannot access your filesystem or execute system commands.
@@ -65,7 +64,7 @@ const SecuritySettings = ({ collection }) => {
/>
Developer Mode
- (use only if you trust the collections authors)
+ (use only if you trust the authors of the collection)
@@ -75,9 +74,6 @@ const SecuritySettings = ({ collection }) => {
Save
-
- * SAFE mode has been introduced v1.26 onwards and is in beta. Please report any issues on github.
-
);
diff --git a/packages/bruno-app/src/components/SensitiveFieldWarning/StyledWrapper.js b/packages/bruno-app/src/components/SensitiveFieldWarning/StyledWrapper.js
new file mode 100644
index 000000000..97a9389d8
--- /dev/null
+++ b/packages/bruno-app/src/components/SensitiveFieldWarning/StyledWrapper.js
@@ -0,0 +1,10 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ .tooltip-mod {
+ font-size: 11px !important;
+ width: 150px !important;
+ }
+`;
+
+export default Wrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/SensitiveFieldWarning/index.js b/packages/bruno-app/src/components/SensitiveFieldWarning/index.js
new file mode 100644
index 000000000..2b2cce326
--- /dev/null
+++ b/packages/bruno-app/src/components/SensitiveFieldWarning/index.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import { IconAlertTriangle } from '@tabler/icons';
+import { Tooltip } from 'react-tooltip';
+import StyledWrapper from './StyledWrapper';
+
+const SensitiveFieldWarning = ({ fieldName, warningMessage }) => {
+ const tooltipId = `sensitive-field-warning-${fieldName}`;
+
+ return (
+
+
+
+
+
+ {warningMessage}
+
+
+ }
+ />
+
+
+ );
+};
+
+export default SensitiveFieldWarning;
diff --git a/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js b/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js
new file mode 100644
index 000000000..5e1e3be3d
--- /dev/null
+++ b/packages/bruno-app/src/components/ShareCollection/StyledWrapper.js
@@ -0,0 +1,30 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .tabs {
+ .tab {
+ padding: 6px 0px;
+ border: none;
+ border-bottom: solid 2px transparent;
+ margin-right: 1.25rem;
+ color: var(--color-tab-inactive);
+ cursor: pointer;
+
+ &:focus,
+ &:active,
+ &:focus-within,
+ &:focus-visible,
+ &:target {
+ outline: none !important;
+ box-shadow: none !important;
+ }
+
+ &.active {
+ color: ${(props) => props.theme.tabs.active.color} !important;
+ border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/ShareCollection/index.js b/packages/bruno-app/src/components/ShareCollection/index.js
new file mode 100644
index 000000000..7fb5fd523
--- /dev/null
+++ b/packages/bruno-app/src/components/ShareCollection/index.js
@@ -0,0 +1,106 @@
+import React, { useMemo } from 'react';
+import Modal from 'components/Modal';
+import { IconDownload, IconLoader2, IconAlertTriangle } from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+import Bruno from 'components/Bruno';
+import exportBrunoCollection from 'utils/collections/export';
+import exportPostmanCollection from 'utils/exporters/postman-collection';
+import { cloneDeep } from 'lodash';
+import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
+import { useSelector } from 'react-redux';
+import { findCollectionByUid, areItemsLoading } from 'utils/collections/index';
+
+const ShareCollection = ({ onClose, collectionUid }) => {
+ const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
+ const isCollectionLoading = areItemsLoading(collection);
+
+ const hasGrpcRequests = useMemo(() => {
+ const checkItem = (item) => {
+ if (item.type === 'grpc-request') {
+ return true;
+ }
+ if (item.items) {
+ return item.items.some(checkItem);
+ }
+ return false;
+ };
+ return collection?.items?.some(checkItem) || false;
+ }, [collection]);
+
+ const handleExportBrunoCollection = () => {
+ const collectionCopy = cloneDeep(collection);
+ exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));
+ onClose();
+ };
+
+ const handleExportPostmanCollection = () => {
+ const collectionCopy = cloneDeep(collection);
+ exportPostmanCollection(collectionCopy);
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ {isCollectionLoading ? : }
+
+
+
Bruno Collection
+
{isCollectionLoading ? 'Loading collection...' : 'Export in Bruno format'}
+
+
+
+
+ {hasGrpcRequests && (
+
+
+ Note: gRPC requests in this collection will not be exported
+
+ )}
+
+
+ {isCollectionLoading ? (
+
+ ) : (
+
+ )}
+
+
+
Postman Collection
+
+ {isCollectionLoading ? 'Loading collection...' : 'Export in Postman format'}
+
+
+
+
+
+
+
+ );
+};
+
+export default ShareCollection;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js
index 41d3e5ff2..a0a1e6c09 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CloneCollection/index.js
@@ -1,33 +1,44 @@
import React, { useRef, useEffect } from 'react';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { cloneCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
-import InfoTip from 'components/InfoTip';
import Modal from 'components/Modal';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import Help from 'components/Help';
+import PathDisplay from 'components/PathDisplay';
+import { useState } from 'react';
+import { IconArrowBackUp, IconEdit } from "@tabler/icons";
+import { findCollectionByUid } from 'utils/collections/index';
-const CloneCollection = ({ onClose, collection }) => {
+const CloneCollection = ({ onClose, collectionUid }) => {
const inputRef = useRef();
const dispatch = useDispatch();
+ const [isEditing, toggleEditing] = useState(false);
+ const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
+ const { name } = collection;
const formik = useFormik({
enableReinitialize: true,
initialValues: {
- collectionName: '',
- collectionFolderName: '',
+ collectionName: `${name} copy`,
+ collectionFolderName: `${sanitizeName(name)} copy`,
collectionLocation: ''
},
validationSchema: Yup.object({
collectionName: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'must be 255 characters or less')
.required('collection name is required'),
collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
+ .max(255, 'must be 255 characters or less')
+ .test('is-valid-collection-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.required('folder name is required'),
collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
}),
@@ -37,7 +48,7 @@ const CloneCollection = ({ onClose, collection }) => {
values.collectionName,
values.collectionFolderName,
values.collectionLocation,
- collection.pathname
+ collection?.pathname
)
)
.then(() => {
@@ -51,7 +62,7 @@ const CloneCollection = ({ onClose, collection }) => {
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
- // When the user closes the diolog without selecting anything dirPath will be false
+ // When the user closes the dialog without selecting anything dirPath will be false
if (typeof dirPath === 'string') {
formik.setFieldValue('collectionLocation', dirPath);
}
@@ -85,9 +96,7 @@ const CloneCollection = ({ onClose, collection }) => {
className="block textbox mt-2 w-full"
onChange={(e) => {
formik.handleChange(e);
- if (formik.values.collectionName === formik.values.collectionFolderName) {
- formik.setFieldValue('collectionFolderName', e.target.value);
- }
+ !isEditing && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
}}
autoComplete="off"
autoCorrect="off"
@@ -119,33 +128,70 @@ const CloneCollection = ({ onClose, collection }) => {
{formik.errors.collectionLocation}
) : null}
-
+
Browse
-
- Folder Name
-
-
-
- {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
-
{formik.errors.collectionFolderName}
- ) : null}
+
+
+
+ Folder Name
+
+
+ The name of the folder used to store the collection.
+
+
+ You can choose a folder name different from your collection's name or one compatible with filesystem rules.
+
+
+
+ {isEditing ? (
+
toggleEditing(false)}
+ />
+ ) : (
+ toggleEditing(true)}
+ />
+ )}
+
+ {isEditing ? (
+
+ ) : (
+
+ )}
+
+ {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
+
{formik.errors.collectionFolderName}
+ ) : null}
+
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/StyledWrapper.js
new file mode 100644
index 000000000..d46e186d2
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .advanced-options {
+ .caret {
+ color: ${(props) => props.theme.textLink};
+ fill: ${(props) => props.theme.textLink};
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js
index 0bf17603d..3a4e2e9c8 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index.js
@@ -1,4 +1,4 @@
-import React, { useRef, useEffect } from 'react';
+import React, { useState, useRef, useEffect, forwardRef } from 'react';
import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
@@ -6,24 +6,50 @@ import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
+import { IconArrowBackUp, IconEdit, IconCaretDown } from "@tabler/icons";
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import Help from 'components/Help';
+import PathDisplay from 'components/PathDisplay/index';
+import path from 'utils/common/path';
+import Portal from 'components/Portal';
+import Dropdown from 'components/Dropdown';
+import StyledWrapper from './StyledWrapper';
-const CloneCollectionItem = ({ collection, item, onClose }) => {
+const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
+ const [isEditing, toggleEditing] = useState(false);
+ const itemName = item?.name;
+ const itemType = item?.type;
+ const [showFilesystemName, toggleShowFilesystemName] = useState(false);
+
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
const formik = useFormik({
enableReinitialize: true,
initialValues: {
- name: item.name
+ name: `${itemName} copy`,
+ filename: `${sanitizeName(itemName)} copy`
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'must be 255 characters or less')
+ .required('name is required'),
+ filename: Yup.string()
+ .min(1, 'must be at least 1 character')
+ .max(255, 'must be 255 characters or less')
.required('name is required')
+ .test('is-valid-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
+ .test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}),
onSubmit: (values) => {
- dispatch(cloneItem(values.name, item.uid, collection.uid))
+ dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid))
.then(() => {
toast.success('Request cloned!');
onClose();
@@ -40,39 +66,157 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
}
}, [inputRef]);
- const onSubmit = () => formik.handleSubmit();
+ const AdvancedOptions = forwardRef((props, ref) => {
+ return (
+
+
+ Options
+
+
+
+ );
+ });
return (
-
-
-
+
+
+
+
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/StyledWrapper.js
similarity index 53%
rename from packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js
rename to packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/StyledWrapper.js
index 7fd98347c..62f53069e 100644
--- a/packages/bruno-app/src/components/CollectionSettings/Info/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/StyledWrapper.js
@@ -1,12 +1,8 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
- table {
- td {
- &:first-child {
- width: 120px;
- }
- }
+ .drag-preview {
+ background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
`;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/index.js
new file mode 100644
index 000000000..fa1fb960b
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemDragPreview/index.js
@@ -0,0 +1,50 @@
+import { useDragLayer } from 'react-dnd';
+import {
+ IconFile,
+ IconFolder,
+} from '@tabler/icons';
+import StyledWrapper from './StyledWrapper';
+
+function getItemStyles({ x, y }) {
+ if (Number.isNaN(x) || Number.isNaN(y)) return { display: 'none' };
+ const transform = `translate(${x}px, ${y}px)`;
+
+ return {
+ position: 'fixed',
+ pointerEvents: 'none',
+ top: 0,
+ transform,
+ WebkitTransform: transform,
+ zIndex: 100,
+ };
+}
+
+export const CollectionItemDragPreview = () => {
+ const {
+ item,
+ isDragging,
+ clientOffset
+ } = useDragLayer((monitor) => ({
+ item: monitor.getItem(),
+ isDragging: monitor.isDragging(),
+ clientOffset: monitor.getClientOffset(),
+ }));
+ if (!isDragging) return null;
+ if (!item.type) return null;
+ const { x, y } = clientOffset || {};
+ const shouldShowFolderIcon = item.type === 'folder';
+ return (
+
+
+
+ {shouldShowFolderIcon ? (
+
+ ) : (
+
+ )}
+ {item.name}
+
+
+
+ );
+};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/StyledWrapper.js
new file mode 100644
index 000000000..66bfe719b
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ .partial {
+ color: ${(props) => props.theme.colors.text.yellow};
+ }
+ .error {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js
new file mode 100644
index 000000000..82d87aa7d
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js
@@ -0,0 +1,21 @@
+import RequestMethod from "../RequestMethod";
+import { IconLoader2, IconAlertTriangle, IconAlertCircle } from '@tabler/icons';
+import StyledWrapper from "./StyledWrapper";
+
+const CollectionItemIcon = ({ item }) => {
+ if (item?.error) {
+ return
;
+ }
+
+ if (item?.loading) {
+ return
;
+ }
+
+ if (item?.partial) {
+ return
;
+ }
+
+ return
;
+};
+
+export default CollectionItemIcon;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemInfo/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemInfo/index.js
new file mode 100644
index 000000000..ca46d0d79
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemInfo/index.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import Modal from 'components/Modal';
+import Help from 'components/Help';
+
+const CollectionItemInfo = ({ item, onClose }) => {
+ const { name, filename, type } = item;
+
+ return (
+
+
+
+
+
+
+ {type=='folder' ? 'Folder Name' : 'Request Name'}
+
+
+ : {name}
+
+
+
+
+ {type == 'folder' ? 'Folder Name' : 'File Name'}
+ (on filesystem)
+ {type == 'folder' ? (
+
+
+ The name of the folder on your filesystem.
+
+
+ ) : (
+
+
+ Bruno saves each request as a file in your collection's folder.
+
+
+ )}
+
+
+ :
+ {filename}
+
+
+
+
+
+
+ );
+};
+
+export default CollectionItemInfo;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js
index 2646bf676..3f397c78c 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/DeleteCollectionItem/index.js
@@ -7,11 +7,11 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
-const DeleteCollectionItem = ({ onClose, item, collection }) => {
+const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const onConfirm = () => {
- dispatch(deleteItem(item.uid, collection.uid)).then(() => {
+ dispatch(deleteItem(item.uid, collectionUid)).then(() => {
if (isFolder) {
// close all tabs that belong to the folder
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js
index ff06f4f31..181a258ae 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/StyledWrapper.js
@@ -1,19 +1,59 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
- position: relative;
height: 100%;
+ position: relative;
+
+ .editor-content {
+ height: 100%;
+
+ .CodeMirror {
+ height: 100%;
+ font-size: 12px;
+ line-height: 1.5;
+ padding: 0;
+
+ .CodeMirror-gutters {
+ background: ${props => props.theme.codemirror.gutter.bg};
+ border-right: 1px solid ${props => props.theme.codemirror.border};
+ }
+
+ .CodeMirror-linenumber {
+ color: ${props => props.theme.colors.text.muted};
+ font-size: 11px;
+ padding: 0 3px 0 5px;
+ }
+
+ .CodeMirror-lines {
+ padding: 0;
+ }
+
+ .CodeMirror-line {
+ padding: 0 4px;
+ }
+ }
+ }
.copy-to-clipboard {
position: absolute;
- cursor: pointer;
top: 10px;
right: 10px;
z-index: 10;
- opacity: 0.5;
+ background: transparent;
+ border: none;
+ color: ${props => props.theme.colors.text.muted};
+ cursor: pointer;
+ padding: 6px;
+ opacity: 0.7;
+ transition: all 0.2s ease;
&:hover {
opacity: 1;
+ color: ${props => props.theme.text};
+ }
+
+ &:active {
+ transform: translateY(1px);
}
}
`;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
index 3092df4ba..34b779370 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeView/index.js
@@ -1,75 +1,66 @@
import CodeEditor from 'components/CodeEditor/index';
import get from 'lodash/get';
-import { HTTPSnippet } from 'httpsnippet';
import { useTheme } from 'providers/Theme/index';
import StyledWrapper from './StyledWrapper';
-import { buildHarRequest } from 'utils/codegenerator/har';
import { useSelector } from 'react-redux';
import { CopyToClipboard } from 'react-copy-to-clipboard';
import toast from 'react-hot-toast';
import { IconCopy } from '@tabler/icons';
-import { findCollectionByItemUid, getGlobalEnvironmentVariables } from '../../../../../../../utils/collections/index';
-import { getAuthHeaders } from '../../../../../../../utils/codegenerator/auth';
+import { findCollectionByItemUid, getGlobalEnvironmentVariables } from 'utils/collections/index';
import { cloneDeep } from 'lodash';
+import { useMemo } from 'react';
+import { generateSnippet } from '../utils/snippet-generator';
const CodeView = ({ language, item }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
- const { target, client, language: lang } = language;
- const requestHeaders = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
- let _collection = findCollectionByItemUid(
+ const generateCodePrefs = useSelector((state) => state.app.generateCode);
+
+ let collectionOriginal = findCollectionByItemUid(
useSelector((state) => state.collections.collections),
item.uid
);
- let collection = cloneDeep(_collection);
+ const collection = useMemo(() => {
+ const c = cloneDeep(collectionOriginal);
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({
+ globalEnvironments,
+ activeGlobalEnvironmentUid
+ });
+ c.globalEnvironmentVariables = globalEnvironmentVariables;
+ return c;
+ }, [collectionOriginal, globalEnvironments, activeGlobalEnvironmentUid]);
- // add selected global env variables to the collection object
- const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
- collection.globalEnvironmentVariables = globalEnvironmentVariables;
-
- const collectionRootAuth = collection?.root?.request?.auth;
- const requestAuth = item.draft ? get(item, 'draft.request.auth') : get(item, 'request.auth');
-
- const headers = [
- ...getAuthHeaders(collectionRootAuth, requestAuth),
- ...(collection?.root?.request?.headers || []),
- ...(requestHeaders || [])
- ];
-
- let snippet = '';
- try {
- snippet = new HTTPSnippet(buildHarRequest({ request: item.request, headers, type: item.type })).convert(
- target,
- client
- );
- } catch (e) {
- console.error(e);
- snippet = 'Error generating code snippet';
- }
+ const snippet = useMemo(() => {
+ return generateSnippet({ language, item, collection, shouldInterpolate: generateCodePrefs.shouldInterpolate });
+ }, [language, item, collection, generateCodePrefs.shouldInterpolate]);
return (
- <>
-
- toast.success('Copied to clipboard!')}
- >
+
+ toast.success('Copied to clipboard!')}
+ >
+
-
+
+
+
-
- >
+
+
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js
new file mode 100644
index 000000000..c73d2ae39
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/StyledWrapper.js
@@ -0,0 +1,117 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background: ${props => props.theme.requestTabPanel.card.bg};
+ border-bottom: 1px solid ${props => props.theme.requestTabPanel.card.border};
+ gap: 12px;
+ flex-shrink: 0;
+ }
+
+ .left-controls {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+
+ .select-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ }
+
+ .select-arrow {
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ pointer-events: none;
+ color: ${props => props.theme.colors.text.muted};
+ }
+
+ .native-select {
+ background: ${props => props.theme.requestTabPanel.url.bg};
+ border: 1px solid ${props => props.theme.input.border};
+ border-radius: 3px;
+ color: ${props => props.theme.text};
+ font-size: 12px;
+ padding: 6px 28px 6px 10px;
+ min-width: 140px;
+ height: 32px;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ appearance: none;
+
+ &:hover {
+ border-color: ${props => props.theme.input.focusBorder};
+ }
+
+ &:focus {
+ outline: none;
+ border-color: ${props => props.theme.input.focusBorder};
+ box-shadow: 0 0 0 2px ${props => props.theme.input.focusBoxShadow};
+ }
+
+ option {
+ background: ${props => props.theme.bg};
+ color: ${props => props.theme.text};
+ padding: 8px 12px;
+ }
+ }
+
+ .library-options {
+ display: flex;
+ gap: 6px;
+ }
+
+ .lib-btn {
+ height: 32px;
+ padding: 0 12px;
+ background: ${props => props.theme.requestTabPanel.url.bg};
+ border: 1px solid ${props => props.theme.input.border};
+ border-radius: 3px;
+ color: ${props => props.theme.text};
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ display: flex;
+ align-items: center;
+
+ &:hover {
+ background: ${props => props.theme.dropdown.hoverBg};
+ border-color: ${props => props.theme.input.focusBorder};
+ }
+
+ &.active {
+ background: ${props => props.theme.button.secondary.bg};
+ border-color: ${props => props.theme.button.secondary.border};
+ color: ${props => props.theme.button.secondary.color};
+ }
+ }
+
+ .right-controls {
+ .interpolate-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ cursor: pointer;
+ font-size: 13px;
+ color: ${props => props.theme.text};
+
+ input[type="checkbox"] {
+ cursor: pointer;
+ margin: 0;
+ }
+
+ &:hover {
+ opacity: 0.8;
+ }
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js
new file mode 100644
index 000000000..2e63ce384
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/CodeViewToolbar/index.js
@@ -0,0 +1,106 @@
+import { IconChevronDown } from '@tabler/icons';
+import { useSelector, useDispatch } from 'react-redux';
+import { useMemo } from 'react';
+import { getLanguages } from 'utils/codegenerator/targets';
+import { updateGenerateCode } from 'providers/ReduxStore/slices/app';
+import StyledWrapper from './StyledWrapper';
+
+const CodeViewToolbar = () => {
+ const dispatch = useDispatch();
+ const languages = getLanguages();
+ const generateCodePrefs = useSelector((state) => state.app.generateCode);
+
+ // Group languages by their main language type
+ const languageGroups = useMemo(() => {
+ return languages.reduce((acc, lang) => {
+ const mainLang = lang.name.split('-')[0];
+ if (!acc[mainLang]) {
+ acc[mainLang] = [];
+ }
+ acc[mainLang].push({
+ ...lang,
+ libraryName: lang.name.split('-')[1] || 'default'
+ });
+ return acc;
+ }, {});
+ }, [languages]);
+
+ const mainLanguages = useMemo(() => Object.keys(languageGroups), [languageGroups]);
+
+ const availableLibraries = useMemo(() => {
+ return languageGroups[generateCodePrefs.mainLanguage] || [];
+ }, [generateCodePrefs.mainLanguage, languageGroups]);
+
+ // Event handlers
+ const handleMainLanguageChange = (e) => {
+ const newMainLang = e.target.value;
+ const defaultLibrary = languageGroups[newMainLang][0].libraryName;
+
+ dispatch(updateGenerateCode({
+ mainLanguage: newMainLang,
+ library: defaultLibrary
+ }));
+ };
+
+ const handleLibraryChange = (libraryName) => {
+ dispatch(updateGenerateCode({
+ library: libraryName
+ }));
+ };
+
+ const handleInterpolateChange = (e) => {
+ dispatch(updateGenerateCode({
+ shouldInterpolate: e.target.checked
+ }));
+ };
+
+ return (
+
+
+
+
+
+ {mainLanguages.map((lang) => (
+
+ {lang}
+
+ ))}
+
+
+
+
+ {availableLibraries.length > 1 && (
+
+ {availableLibraries.map((lib) => (
+ handleLibraryChange(lib.libraryName)}
+ >
+ {lib.libraryName}
+
+ ))}
+
+ )}
+
+
+
+
+
+ Interpolate Variables
+
+
+
+
+ );
+};
+
+export default CodeViewToolbar;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js
index 3d8ea1229..324e9ec3c 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/StyledWrapper.js
@@ -1,60 +1,44 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
- margin-inline: -1rem;
- margin-block: -1.5rem;
+ margin: -1.5rem -1rem;
+ height: 50vh;
+ display: flex;
+ flex-direction: column;
background-color: ${(props) => props.theme.collection.environment.settings.bg};
- .generate-code-sidebar {
- background-color: ${(props) => props.theme.collection.environment.settings.sidebar.bg};
- border-right: solid 1px ${(props) => props.theme.collection.environment.settings.sidebar.borderRight};
- max-height: 80vh;
+ .code-generator {
+ display: flex;
+ flex-direction: column;
height: 100%;
- overflow-y: auto;
}
- .generate-code-item {
- min-width: 150px;
- display: block;
+ .editor-container {
+ flex: 1;
+ overflow: hidden;
position: relative;
- cursor: pointer;
- padding: 8px 10px;
- border-left: solid 2px transparent;
- text-decoration: none;
+ background: ${props => props.theme.bg};
+ }
- &:hover {
- text-decoration: none;
- background-color: ${(props) => props.theme.collection.environment.settings.item.hoverBg};
+ .error-message {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ color: ${props => props.theme.colors.text.muted};
+ text-align: center;
+ padding: 20px;
+
+ h1 {
+ font-size: 14px;
+ margin-bottom: 8px;
+ color: ${props => props.theme.text};
}
- }
- .active {
- background-color: ${(props) => props.theme.collection.environment.settings.item.active.bg} !important;
- border-left: solid 2px ${(props) => props.theme.collection.environment.settings.item.border};
- &:hover {
- background-color: ${(props) => props.theme.collection.environment.settings.item.active.hoverBg} !important;
- }
- }
-
- .flexible-container {
- width: 100%;
- }
-
- @media (max-width: 600px) {
- .flexible-container {
- width: 500px;
- }
- }
-
- @media (min-width: 601px) and (max-width: 1200px) {
- .flexible-container {
- width: 800px;
- }
- }
-
- @media (min-width: 1201px) {
- .flexible-container {
- width: 900px;
+ p {
+ font-size: 12px;
+ opacity: 0.8;
}
}
`;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js
index 792736b12..fbef672eb 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/index.js
@@ -1,22 +1,30 @@
import Modal from 'components/Modal/index';
-import { useState } from 'react';
+import { useMemo } from 'react';
import CodeView from './CodeView';
+import CodeViewToolbar from './CodeViewToolbar';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url';
import { get } from 'lodash';
-import { findEnvironmentInCollection } from 'utils/collections';
+import {
+ findEnvironmentInCollection
+} from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
-import { getGlobalEnvironmentVariables } from 'utils/collections/index';
+import { getAllVariables, getGlobalEnvironmentVariables } from 'utils/collections/index';
+import { resolveInheritedAuth } from './utils/auth-utils';
-const GenerateCodeItem = ({ collection, item, onClose }) => {
+const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const languages = getLanguages();
-
+ const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
- const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
-
+ const generateCodePrefs = useSelector((state) => state.app.generateCode);
+ const globalEnvironmentVariables = getGlobalEnvironmentVariables({
+ globalEnvironments,
+ activeGlobalEnvironmentUid
+ });
const environment = findEnvironmentInCollection(collection, collection?.activeEnvironmentUid);
+
let envVars = {};
if (environment) {
const vars = get(environment, 'variables', []);
@@ -29,13 +37,13 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
const requestUrl =
get(item, 'draft.request.url') !== undefined ? get(item, 'draft.request.url') : get(item, 'request.url');
- // interpolate the url
+ const variables = useMemo(() => {
+ return getAllVariables({ ...collection, globalEnvironmentVariables }, item);
+ }, [collection, globalEnvironmentVariables, item]);
+
const interpolatedUrl = interpolateUrl({
url: requestUrl,
- globalEnvironmentVariables,
- envVars,
- runtimeVariables: collection.runtimeVariables,
- processEnvVars: collection.processEnvVariables
+ variables
});
// interpolate the path params
@@ -44,72 +52,40 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
);
- const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
+ // Get the full language object based on current preferences
+ const selectedLanguage = useMemo(() => {
+ const fullName = generateCodePrefs.library === 'default'
+ ? generateCodePrefs.mainLanguage
+ : `${generateCodePrefs.mainLanguage}-${generateCodePrefs.library}`;
+
+ return languages.find(lang => lang.name === fullName) || languages[0];
+ }, [generateCodePrefs.mainLanguage, generateCodePrefs.library, languages]);
+
+ // Resolve auth inheritance
+ const resolvedRequest = resolveInheritedAuth(item, collection);
+
return (
-
-
-
- {languages &&
- languages.length &&
- languages.map((language) => (
-
setSelectedLanguage(language)}
- onKeyDown={(e) => {
- if (e.key === 'Tab' || (e.shiftKey && e.key === 'Tab')) {
- e.preventDefault();
- const currentIndex = languages.findIndex((lang) => lang.name === selectedLanguage.name);
- const nextIndex = e.shiftKey
- ? (currentIndex - 1 + languages.length) % languages.length
- : (currentIndex + 1) % languages.length;
- setSelectedLanguage(languages[nextIndex]);
+
+
- // Explicitly focus on the new active element
- const nextElement = document.querySelector(`[data-language="${languages[nextIndex].name}"]`);
- nextElement?.focus();
- }
-
- }}
- data-language={language.name}
- aria-pressed={language.name === selectedLanguage.name}
- >
- {language.name}
-
- ))}
-
-
-
+
{isValidUrl(finalUrl) ? (
) : (
-
-
-
Invalid URL: {finalUrl}
-
Please check the URL and try again
-
+
+
Invalid URL: {finalUrl}
+
Please check the URL and try again
)}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js
new file mode 100644
index 000000000..f9885f0df
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.js
@@ -0,0 +1,42 @@
+import { get } from 'lodash';
+import {
+ getTreePathFromCollectionToItem
+} from 'utils/collections/index';
+
+// Resolve inherited auth by traversing up the folder hierarchy
+export const resolveInheritedAuth = (item, collection) => {
+ const mergedRequest = {
+ ...(item.request || {}),
+ ...(item.draft?.request || {})
+ };
+
+ const authMode = mergedRequest.auth.mode;
+
+ // If auth is not inherit or no auth defined, return the merged request as is
+ if (!authMode || authMode !== 'inherit') {
+ return mergedRequest;
+ }
+
+ // Get the tree path from collection to item
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+
+ // Default to collection auth
+ const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
+ let effectiveAuth = collectionAuth;
+
+ // Check folders in reverse to find the closest auth configuration
+ for (let i of [...requestTreePath].reverse()) {
+ if (i.type === 'folder') {
+ const folderAuth = get(i, 'root.request.auth');
+ if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
+ effectiveAuth = folderAuth;
+ break;
+ }
+ }
+ }
+
+ return {
+ ...mergedRequest,
+ auth: effectiveAuth
+ };
+};
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js
new file mode 100644
index 000000000..ad5afc3e6
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/auth-utils.spec.js
@@ -0,0 +1,79 @@
+import { resolveInheritedAuth } from './auth-utils';
+
+jest.mock('utils/collections/index', () => ({
+ getTreePathFromCollectionToItem: (collection, item) => {
+ const itemUid = item.uid;
+
+ if (itemUid === 'r1') {
+ return [collection.items[0], collection.items[0].items[0]];
+ }
+ return [];
+ }
+}));
+
+// Helper to build mock collection structure
+const buildCollection = () => {
+ return {
+ uid: 'c1',
+ root: {
+ request: {
+ auth: { mode: 'bearer', bearer: { token: 'COLLECTION' } }
+ }
+ },
+ items: [
+ {
+ uid: 'f1',
+ type: 'folder',
+ name: 'Folder',
+ root: {
+ request: {
+ auth: { mode: 'basic', basic: { username: 'user', password: 'pass' } }
+ }
+ },
+ items: [
+ {
+ uid: 'r1',
+ type: 'request',
+ name: 'Request',
+ request: {
+ auth: { mode: 'inherit' },
+ url: 'http://example.com',
+ method: 'GET'
+ }
+ }
+ ]
+ }
+ ]
+ };
+};
+
+describe('auth-utils.resolveInheritedAuth', () => {
+ it('should resolve to nearest folder auth when request mode is inherit', () => {
+ const collection = buildCollection();
+ const item = collection.items[0].items[0]; // r1
+
+ const resolved = resolveInheritedAuth(item, collection);
+ expect(resolved.auth.mode).toBe('basic');
+ expect(resolved.auth.basic.username).toBe('user');
+ });
+
+ it('should resolve to collection auth if no folder auth', () => {
+ const collection = buildCollection();
+ collection.items[0].root.request.auth = { mode: 'inherit' };
+ const item = collection.items[0].items[0];
+
+ const resolved = resolveInheritedAuth(item, collection);
+ expect(resolved.auth.mode).toBe('bearer');
+ expect(resolved.auth.bearer.token).toBe('COLLECTION');
+ });
+
+ it('should return original request when mode is not inherit', () => {
+ const collection = buildCollection();
+ const item = collection.items[0].items[0];
+ item.request.auth = { mode: 'basic', basic: { username: 'override', password: 'pwd' } };
+
+ const resolved = resolveInheritedAuth(item, collection);
+ expect(resolved.auth.mode).toBe('basic');
+ expect(resolved.auth.basic.username).toBe('override');
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js
new file mode 100644
index 000000000..e7081b268
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.js
@@ -0,0 +1,71 @@
+import { interpolate } from '@usebruno/common';
+import { cloneDeep } from 'lodash';
+
+export const interpolateHeaders = (headers = [], variables = {}) => {
+ return headers.map((header) => ({
+ ...header,
+ name: interpolate(header.name, variables),
+ value: interpolate(header.value, variables)
+ }));
+};
+
+export const interpolateBody = (body, variables = {}) => {
+ if (!body) return null;
+
+ const interpolatedBody = cloneDeep(body);
+
+ switch (body.mode) {
+ case 'json':
+ let parsed = body.json;
+ // If it's already a string, use it directly; if it's an object, stringify it first
+ if (typeof parsed === 'object') {
+ parsed = JSON.stringify(parsed);
+ }
+ parsed = interpolate(parsed, variables, { escapeJSONStrings: true });
+ try {
+ const jsonObj = JSON.parse(parsed);
+ interpolatedBody.json = JSON.stringify(jsonObj, null, 2);
+ } catch {
+ interpolatedBody.json = parsed;
+ }
+ break;
+
+ case 'text':
+ interpolatedBody.text = interpolate(body.text, variables);
+ break;
+
+ case 'xml':
+ interpolatedBody.xml = interpolate(body.xml, variables);
+ break;
+
+ case 'sparql':
+ interpolatedBody.sparql = interpolate(body.sparql, variables);
+ break;
+
+ case 'formUrlEncoded':
+ interpolatedBody.formUrlEncoded = Array.isArray(body.formUrlEncoded)
+ ? body.formUrlEncoded.map((param) => ({
+ ...param,
+ value: param.enabled ? interpolate(param.value, variables) : param.value
+ }))
+ : [];
+ break;
+
+ case 'multipartForm':
+ interpolatedBody.multipartForm = Array.isArray(body.multipartForm)
+ ? body.multipartForm.map((param) => ({
+ ...param,
+ value:
+ param.type === 'text' && param.enabled
+ ? interpolate(param.value, variables)
+ : param.value
+ }))
+ : [];
+ break;
+
+ default:
+ break;
+ }
+
+ return interpolatedBody;
+};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js
new file mode 100644
index 000000000..8c5920b76
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/interpolation.spec.js
@@ -0,0 +1,48 @@
+import { interpolateHeaders, interpolateBody } from './interpolation';
+
+describe('interpolation utils', () => {
+ describe('interpolateHeaders', () => {
+ it('should interpolate variables in header name and value while preserving other props', () => {
+ const headers = [
+ { uid: '1', name: 'X-{{var}}', value: 'value-{{var}}', enabled: true }
+ ];
+ const variables = { var: 'test' };
+
+ const result = interpolateHeaders(headers, variables);
+ expect(result).toEqual([
+ {
+ uid: '1',
+ name: 'X-test',
+ value: 'value-test',
+ enabled: true
+ }
+ ]);
+ });
+ });
+
+ describe('interpolateBody', () => {
+ it('should interpolate JSON body strings and keep formatting', () => {
+ const body = {
+ mode: 'json',
+ json: '{"name": "{{username}}"}'
+ };
+ const variables = { username: 'bruno' };
+
+ const result = interpolateBody(body, variables);
+ expect(result.json).toBe('{\n "name": "bruno"\n}');
+ });
+
+ it('should interpolate text body', () => {
+ const body = {
+ mode: 'text',
+ text: 'Hello {{name}}'
+ };
+ const result = interpolateBody(body, { name: 'World' });
+ expect(result.text).toBe('Hello World');
+ });
+
+ it('should return null when body is null', () => {
+ expect(interpolateBody(null, { a: 1 })).toBeNull();
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js
new file mode 100644
index 000000000..41d9236ed
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.js
@@ -0,0 +1,92 @@
+import { buildHarRequest } from 'utils/codegenerator/har';
+import { getAuthHeaders } from 'utils/codegenerator/auth';
+import { getAllVariables, getTreePathFromCollectionToItem } from 'utils/collections/index';
+import { interpolateHeaders, interpolateBody } from './interpolation';
+
+// Merge headers from collection, folders, and request
+const mergeHeaders = (collection, request, requestTreePath) => {
+ let headers = new Map();
+
+ // Add collection headers first
+ const collectionHeaders = collection?.root?.request?.headers || [];
+ collectionHeaders.forEach((header) => {
+ if (header.enabled) {
+ headers.set(header.name, header);
+ }
+ });
+
+ // Add folder headers next, traversing from root to leaf
+ if (requestTreePath && requestTreePath.length > 0) {
+ for (let i of requestTreePath) {
+ if (i.type === 'folder') {
+ const folderHeaders = i?.root?.request?.headers || [];
+ folderHeaders.forEach((header) => {
+ if (header.enabled) {
+ headers.set(header.name, header);
+ }
+ });
+ }
+ }
+ }
+
+ // Add request headers last (they take precedence)
+ const requestHeaders = request.headers || [];
+ requestHeaders.forEach((header) => {
+ if (header.enabled) {
+ headers.set(header.name, header);
+ }
+ });
+
+ // Convert Map back to array
+ return Array.from(headers.values());
+};
+
+const generateSnippet = ({ language, item, collection, shouldInterpolate = false }) => {
+ try {
+ // Get HTTPSnippet dynamically so mocks can be applied in tests
+ const { HTTPSnippet } = require('httpsnippet');
+
+ const variables = getAllVariables(collection, item);
+
+ const request = item.request;
+
+ // Get the request tree path and merge headers
+ const requestTreePath = getTreePathFromCollectionToItem(collection, item);
+ let headers = mergeHeaders(collection, request, requestTreePath);
+
+ // Add auth headers if needed
+ if (request.auth && request.auth.mode !== 'none') {
+ const collectionAuth = collection?.root?.request?.auth || null;
+ const authHeaders = getAuthHeaders(collectionAuth, request.auth);
+ headers = [...headers, ...authHeaders];
+ }
+
+ // Interpolate headers and body if needed
+ if (shouldInterpolate) {
+ headers = interpolateHeaders(headers, variables);
+ if (request.body) {
+ request.body = interpolateBody(request.body, variables);
+ }
+ }
+
+ // Build HAR request
+ const harRequest = buildHarRequest({
+ request,
+ headers
+ });
+
+ // Generate snippet using HTTPSnippet
+ const snippet = new HTTPSnippet(harRequest);
+ const result = snippet.convert(language.target, language.client);
+
+ return result;
+ } catch (error) {
+ console.error('Error generating code snippet:', error);
+ return 'Error generating code snippet';
+ }
+};
+
+export {
+ generateSnippet,
+ mergeHeaders
+};
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js
new file mode 100644
index 000000000..43581b2b4
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/GenerateCodeItem/utils/snippet-generator.spec.js
@@ -0,0 +1,587 @@
+jest.mock('httpsnippet', () => {
+ return {
+ HTTPSnippet: jest.fn().mockImplementation((harRequest) => ({
+ convert: jest.fn(() => {
+ const method = harRequest?.method || 'GET';
+ const url = harRequest?.url || 'http://example.com';
+ const hasBody = harRequest?.postData?.text;
+
+ if (method === 'POST' && hasBody) {
+ return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`;
+ }
+ return `curl -X ${method} ${url}`;
+ })
+ }))
+ };
+});
+
+jest.mock('utils/codegenerator/har', () => ({
+ buildHarRequest: jest.fn((data) => {
+ const request = data.request || {};
+ const method = request.method || 'GET';
+ const url = request.url || 'http://example.com';
+ const body = request.body || {};
+
+ const harRequest = {
+ method: method,
+ url: url,
+ headers: data.headers || [],
+ httpVersion: 'HTTP/1.1'
+ };
+
+ // Add body data for POST requests
+ if (method === 'POST' && body.mode === 'json' && body.json) {
+ harRequest.postData = {
+ mimeType: 'application/json',
+ text: body.json
+ };
+ }
+
+ return harRequest;
+ })
+}));
+
+jest.mock('utils/codegenerator/auth', () => ({
+ getAuthHeaders: jest.fn(() => [])
+}));
+
+jest.mock('utils/collections/index', () => ({
+ getAllVariables: jest.fn((collection) => ({
+ ...collection?.globalEnvironmentVariables,
+ ...collection?.runtimeVariables,
+ ...collection?.processEnvVariables,
+ baseUrl: 'https://api.example.com',
+ apiKey: 'secret-key-123',
+ userId: '12345'
+ })),
+ getTreePathFromCollectionToItem: jest.fn(() => [])
+}));
+
+import { generateSnippet, mergeHeaders } from './snippet-generator';
+
+describe('Snippet Generator - Simple Tests', () => {
+
+ // Simple test request - easy to understand
+ const testRequest = {
+ uid: 'test-request-123',
+ name: 'test api call',
+ type: 'http-request',
+ request: {
+ method: 'POST',
+ url: 'https://api.example.com/{{endpoint}}',
+ headers: [
+ { uid: 'h1', name: 'Authorization', value: 'Bearer {{apiToken}}', enabled: true },
+ { uid: 'h2', name: 'Content-Type', value: 'application/json', enabled: true },
+ { uid: 'h3', name: 'X-Custom', value: '{{customValue}}', enabled: true }
+ ],
+ body: {
+ mode: 'json',
+ json: '{"message": "{{greeting}}", "count": {{number}}}'
+ },
+ auth: { mode: 'none' },
+ assertions: [],
+ tests: '',
+ docs: '',
+ params: [],
+ vars: { req: [] }
+ }
+ };
+
+ const testCollection = {
+ root: {
+ request: {
+ auth: { mode: 'none' },
+ headers: []
+ }
+ },
+ globalEnvironmentVariables: {
+ endpoint: 'data',
+ apiToken: 'token123',
+ customValue: 'test-value',
+ greeting: 'Hello World',
+ number: 42
+ },
+ runtimeVariables: {},
+ processEnvVariables: {}
+ };
+
+ const curlLanguage = { target: 'shell', client: 'curl' };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
+ convert: jest.fn(() => {
+ const method = harRequest?.method || 'GET';
+ const url = harRequest?.url || 'http://example.com';
+ const hasBody = harRequest?.postData?.text;
+
+ if (method === 'POST' && hasBody) {
+ return `curl -X POST ${url} -H "Content-Type: application/json" -d '${hasBody}'`;
+ }
+ return `curl -X ${method} ${url}`;
+ })
+ }));
+ });
+
+ it('should generate curl for POST request with JSON body', () => {
+ const result = generateSnippet({
+ language: curlLanguage,
+ item: testRequest,
+ collection: testCollection,
+ shouldInterpolate: false
+ });
+
+ expect(result).toBe('curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"message": "{{greeting}}", "count": {{number}}}\'');
+ });
+
+ it('should interpolate variables when enabled', () => {
+ const result = generateSnippet({
+ language: curlLanguage,
+ item: testRequest,
+ collection: testCollection,
+ shouldInterpolate: true
+ });
+
+ const expectedBody = `{
+ "message": "Hello World",
+ "count": 42
+}`;
+ expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`);
+ });
+
+ it('should handle GET requests', () => {
+ const getRequest = {
+ ...testRequest,
+ request: {
+ ...testRequest.request,
+ method: 'GET',
+ body: { mode: 'none' }
+ }
+ };
+
+ const result = generateSnippet({
+ language: curlLanguage,
+ item: getRequest,
+ collection: testCollection,
+ shouldInterpolate: false
+ });
+
+ expect(result).toBe('curl -X GET https://api.example.com/{{endpoint}}');
+ });
+
+ it('should handle requests with different headers', () => {
+ const requestWithDifferentHeaders = {
+ ...testRequest,
+ request: {
+ ...testRequest.request,
+ headers: [
+ { uid: 'h1', name: 'X-API-Key', value: '{{apiKey}}', enabled: true },
+ { uid: 'h2', name: 'Accept', value: 'application/json', enabled: true },
+ { uid: 'h3', name: 'User-Agent', value: 'TestApp/{{version}}', enabled: true }
+ ]
+ }
+ };
+
+ const collectionWithDifferentVars = {
+ ...testCollection,
+ globalEnvironmentVariables: {
+ ...testCollection.globalEnvironmentVariables,
+ apiKey: 'secret-key-456',
+ version: '1.0.0'
+ }
+ };
+
+ const result = generateSnippet({
+ language: curlLanguage,
+ item: requestWithDifferentHeaders,
+ collection: collectionWithDifferentVars,
+ shouldInterpolate: true
+ });
+
+ // Body should have interpolated variables with proper formatting
+ const expectedBody = `{
+ "message": "Hello World",
+ "count": 42
+}`;
+ expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedBody}'`);
+ });
+
+ it('should handle complex nested JSON body', () => {
+ const complexBody = {
+ user: {
+ name: '{{userName}}',
+ settings: {
+ theme: '{{userTheme}}',
+ active: true
+ }
+ },
+ data: {
+ items: ['{{item1}}', '{{item2}}'],
+ total: '{{totalCount}}'
+ }
+ };
+
+ const requestWithComplexBody = {
+ ...testRequest,
+ request: {
+ ...testRequest.request,
+ body: {
+ mode: 'json',
+ json: JSON.stringify(complexBody, null, 2)
+ }
+ }
+ };
+
+ const collectionWithComplexVars = {
+ ...testCollection,
+ globalEnvironmentVariables: {
+ ...testCollection.globalEnvironmentVariables,
+ userName: 'Alice',
+ userTheme: 'dark',
+ item1: 'first',
+ item2: 'second',
+ totalCount: 100
+ }
+ };
+
+ const result = generateSnippet({
+ language: curlLanguage,
+ item: requestWithComplexBody,
+ collection: collectionWithComplexVars,
+ shouldInterpolate: true
+ });
+
+ const expectedComplexBody = JSON.stringify({
+ user: {
+ name: 'Alice',
+ settings: {
+ theme: 'dark',
+ active: true
+ }
+ },
+ data: {
+ items: ['first', 'second'],
+ total: '100'
+ }
+ }, null, 2);
+
+ expect(result).toBe(`curl -X POST https://api.example.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedComplexBody}'`);
+ });
+
+ it('should handle errors gracefully', () => {
+ // Set up the error mock after beforeEach has run
+ const originalHTTPSnippet = require('httpsnippet').HTTPSnippet;
+ require('httpsnippet').HTTPSnippet = jest.fn(() => {
+ throw new Error('Mock error!');
+ });
+
+ const originalConsoleError = console.error;
+ console.error = jest.fn();
+
+ const result = generateSnippet({
+ language: curlLanguage,
+ item: testRequest,
+ collection: testCollection,
+ shouldInterpolate: false
+ });
+
+ expect(result).toBe('Error generating code snippet');
+
+ require('httpsnippet').HTTPSnippet = originalHTTPSnippet;
+ console.error = originalConsoleError;
+ });
+
+ it('should work with JavaScript language', () => {
+ const javascriptLanguage = { target: 'javascript', client: 'fetch' };
+
+ const expectedJavaScriptCode = `fetch("https://api.example.com/data", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ "message": "Hello World", "count": 42 })
+})`;
+
+ const originalHTTPSnippet = require('httpsnippet').HTTPSnippet;
+ require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation(() => ({
+ convert: jest.fn(() => expectedJavaScriptCode)
+ }));
+
+ const result = generateSnippet({
+ language: javascriptLanguage,
+ item: testRequest,
+ collection: testCollection,
+ shouldInterpolate: false
+ });
+
+ expect(result).toBe(expectedJavaScriptCode);
+
+ // Restore the original mock
+ require('httpsnippet').HTTPSnippet = originalHTTPSnippet;
+ });
+
+ it('should interpolate simple headers and body variables', () => {
+ const simpleTestRequest = {
+ uid: 'test-123',
+ name: 'simple test',
+ type: 'http-request',
+ request: {
+ method: 'POST',
+ url: 'https://api.test.com/{{endpoint}}',
+ headers: [
+ { uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true },
+ { uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true },
+ { uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true }
+ ],
+ body: {
+ mode: 'json',
+ json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}'
+ }
+ }
+ };
+
+ // Simple collection with clear variable values
+ const simpleTestCollection = {
+ root: {
+ request: {
+ auth: { mode: 'none' },
+ headers: []
+ }
+ },
+ globalEnvironmentVariables: {
+ endpoint: 'users',
+ token: 'abc123token',
+ userId: 'user456',
+ userName: 'John Smith',
+ userEmail: 'john@test.com',
+ userAge: 30
+ },
+ runtimeVariables: {},
+ processEnvVariables: {}
+ };
+
+ const result = generateSnippet({
+ language: curlLanguage,
+ item: simpleTestRequest,
+ collection: simpleTestCollection,
+ shouldInterpolate: true
+ });
+
+ const expectedInterpolatedBody = `{
+ "name": "John Smith",
+ "email": "john@test.com",
+ "age": 30
+}`;
+
+ expect(result).toBe(`curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d '${expectedInterpolatedBody}'`);
+ });
+
+ it('should NOT interpolate when shouldInterpolate is false', () => {
+ const simpleTestRequest = {
+ uid: 'test-123',
+ name: 'simple test',
+ type: 'http-request',
+ request: {
+ method: 'POST',
+ url: 'https://api.test.com/{{endpoint}}',
+ headers: [
+ { uid: 'h1', name: 'Authorization', value: 'Bearer {{token}}', enabled: true },
+ { uid: 'h2', name: 'X-User-ID', value: '{{userId}}', enabled: true },
+ { uid: 'h3', name: 'Content-Type', value: 'application/json', enabled: true }
+ ],
+ body: {
+ mode: 'json',
+ json: '{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}'
+ }
+ }
+ };
+
+ const simpleTestCollection = {
+ root: {
+ request: {
+ auth: { mode: 'none' },
+ headers: []
+ }
+ },
+ globalEnvironmentVariables: {
+ endpoint: 'users',
+ token: 'abc123token',
+ userId: 'user456',
+ userName: 'John Smith',
+ userEmail: 'john@test.com',
+ userAge: 30
+ },
+ runtimeVariables: {},
+ processEnvVariables: {}
+ };
+
+ const result = generateSnippet({
+ language: curlLanguage,
+ item: simpleTestRequest,
+ collection: simpleTestCollection,
+ shouldInterpolate: false
+ });
+
+ expect(result).toBe('curl -X POST https://api.test.com/{{endpoint}} -H "Content-Type: application/json" -d \'{"name": "{{userName}}", "email": "{{userEmail}}", "age": {{userAge}}}\'');
+ });
+});
+
+describe('mergeHeaders', () => {
+ it('should include headers from collection, folder and request (with correct precedence)', () => {
+ const collection = {
+ root: {
+ request: {
+ headers: [
+ { name: 'X-Collection', value: 'c', enabled: true }
+ ]
+ }
+ }
+ };
+
+ const folder = {
+ type: 'folder',
+ root: {
+ request: {
+ headers: [
+ { name: 'X-Folder', value: 'f', enabled: true }
+ ]
+ }
+ }
+ };
+
+ const request = {
+ headers: [
+ { name: 'X-Request', value: 'r', enabled: true }
+ ]
+ };
+
+ const headers = mergeHeaders(collection, request, [folder]);
+ const names = headers.map((h) => h.name);
+ expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request']));
+ });
+});
+
+// Snippet should include inherited headers
+describe('generateSnippet – header inclusion in output', () => {
+ it('should include collection and folder headers in generated snippet', () => {
+ const language = { target: 'shell', client: 'curl' };
+
+ const collection = {
+ root: {
+ request: {
+ headers: [
+ { name: 'X-Collection', value: 'c', enabled: true }
+ ],
+ auth: { mode: 'none' }
+ }
+ }
+ };
+
+ const folder = {
+ uid: 'f1',
+ type: 'folder',
+ root: {
+ request: {
+ headers: [
+ { name: 'X-Folder', value: 'f', enabled: true }
+ ]
+ }
+ }
+ };
+
+ const item = {
+ uid: 'r1',
+ request: {
+ method: 'GET',
+ url: 'https://example.com',
+ headers: [],
+ auth: { mode: 'none' }
+ }
+ };
+
+ // Override tree path to include folder
+ const utilsCollections = require('utils/collections/index');
+ utilsCollections.getTreePathFromCollectionToItem.mockImplementation(() => [folder]);
+
+ // Custom HTTPSnippet mock that outputs headers list
+ const originalHTTPSnippet = require('httpsnippet').HTTPSnippet;
+ require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
+ convert: jest.fn(() => `HEADERS:${harRequest.headers.map((h) => h.name).join(',')}`)
+ }));
+
+ const result = generateSnippet({ language, item, collection, shouldInterpolate: false });
+
+ // Restore original mock
+ require('httpsnippet').HTTPSnippet = originalHTTPSnippet;
+
+ expect(result).toContain('X-Collection');
+ expect(result).toContain('X-Folder');
+ });
+});
+
+describe('generateSnippet with edge-case bodies', () => {
+ const language = { target: 'shell', client: 'curl' };
+ const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } };
+
+ it('should generate snippet for empty formUrlEncoded body when interpolation is disabled', () => {
+ const item = {
+ uid: 'req1',
+ request: {
+ method: 'POST',
+ url: 'https://example.com',
+ headers: [],
+ body: { mode: 'formUrlEncoded', formUrlEncoded: [] },
+ auth: { mode: 'none' }
+ }
+ };
+
+ const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
+ expect(result).toMatch(/^curl -X POST/);
+ });
+
+ it('should generate snippet for empty multipartForm body when interpolation is disabled', () => {
+ const item = {
+ uid: 'req2',
+ request: {
+ method: 'POST',
+ url: 'https://example.com',
+ headers: [],
+ body: { mode: 'multipartForm' },
+ auth: { mode: 'none' }
+ }
+ };
+
+ const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
+ expect(result).toMatch(/^curl -X POST/);
+ });
+
+ it('should generate snippet for undefined formUrlEncoded array with interpolation enabled', () => {
+ const item = {
+ uid: 'req3',
+ request: {
+ method: 'POST',
+ url: 'https://example.com',
+ headers: [],
+ body: { mode: 'formUrlEncoded' },
+ auth: { mode: 'none' }
+ }
+ };
+
+ const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: true });
+ expect(result).toMatch(/^curl -X POST/);
+ });
+
+ it('should generate snippet for empty multipartForm array with interpolation enabled', () => {
+ const item = {
+ uid: 'req4',
+ request: {
+ method: 'POST',
+ url: 'https://example.com',
+ headers: [],
+ body: { mode: 'multipartForm', multipartForm: [] },
+ auth: { mode: 'none' }
+ }
+ };
+
+ const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: true });
+ expect(result).toMatch(/^curl -X POST/);
+ });
+});
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/StyledWrapper.js
new file mode 100644
index 000000000..d46e186d2
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .advanced-options {
+ .caret {
+ color: ${(props) => props.theme.textLink};
+ fill: ${(props) => props.theme.textLink};
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js
index 04744b6d8..6b0cb086a 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RenameCollectionItem/index.js
@@ -1,46 +1,82 @@
-import React, { useRef, useEffect } from 'react';
+import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
+import path from 'utils/common/path';
+import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
+import Help from 'components/Help';
+import PathDisplay from 'components/PathDisplay';
+import Portal from 'components/Portal';
+import Dropdown from 'components/Dropdown';
+import StyledWrapper from './StyledWrapper';
-const RenameCollectionItem = ({ collection, item, onClose }) => {
+const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
+ const [isEditing, toggleEditing] = useState(false);
+ const itemName = item?.name;
+ const itemType = item?.type;
+ const itemFilename = item?.filename ? path.parse(item?.filename).name : '';
+ const [showFilesystemName, toggleShowFilesystemName] = useState(false);
+
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
const formik = useFormik({
enableReinitialize: true,
initialValues: {
- name: item.name
+ name: itemName,
+ filename: sanitizeName(itemFilename)
},
validationSchema: Yup.object({
name: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'must be 255 characters or less')
+ .required('name is required'),
+ filename: Yup.string()
+ .min(1, 'must be at least 1 character')
+ .max(255, 'must be 255 characters or less')
.required('name is required')
+ .test('is-valid-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
+ .test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}),
onSubmit: async (values) => {
// if there is unsaved changes in the request,
// save them before renaming the request
- if (!isFolder && item.draft) {
- await dispatch(saveRequest(item.uid, collection.uid, true));
- }
- if (item.name === values.name) {
+ if ((item.name === values.name) && (itemFilename === values.filename)) {
return;
}
- dispatch(renameItem(values.name, item.uid, collection.uid))
- .then(() => {
- isFolder && dispatch(closeTabs({ tabUids: [item.uid] }));
- toast.success(isFolder ? 'Folder renamed' : 'Request renamed');
- onClose();
- })
- .catch((err) => {
- toast.error(err ? err.message : 'An error occurred while renaming the request');
- });
+ if (!isFolder && item.draft) {
+ await dispatch(saveRequest(item.uid, collectionUid, true));
+ }
+ const { name: newName, filename: newFilename } = values;
+ try {
+ let renameConfig = {
+ itemUid: item.uid,
+ collectionUid,
+ };
+ renameConfig['newName'] = newName;
+ if (itemFilename !== newFilename) {
+ renameConfig['newFilename'] = newFilename;
+ }
+ await dispatch(renameItem(renameConfig));
+ if (isFolder) {
+ dispatch(closeTabs({ tabUids: [item.uid] }));
+ }
+ onClose();
+ } catch (error) {
+ toast.error(error.message || 'An error occurred while renaming');
+ }
}
});
@@ -50,38 +86,155 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
}
}, [inputRef]);
- const onSubmit = () => formik.handleSubmit();
+ const AdvancedOptions = forwardRef((props, ref) => {
+ return (
+
+
+ Options
+
+
+
+ );
+ });
return (
-
-
-
+
+
+
+
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js
index bdb62e843..04d338d5e 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/StyledWrapper.js
@@ -34,6 +34,9 @@ const Wrapper = styled.div`
.method-head {
color: ${(props) => props.theme.request.methods.head};
}
+ .method-grpc {
+ color: ${(props) => props.theme.request.grpc};
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js
index e41309871..73cfc50ed 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RequestMethod/index.js
@@ -3,10 +3,12 @@ import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
const RequestMethod = ({ item }) => {
- if (!['http-request', 'graphql-request'].includes(item.type)) {
+ if (!['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
return null;
}
+ const isGrpc = item.type === 'grpc-request';
+
const getClassname = (method = '') => {
method = method.toLocaleLowerCase();
return classnames('mr-1', {
@@ -16,7 +18,8 @@ const RequestMethod = ({ item }) => {
'method-delete': method === 'delete',
'method-patch': method === 'patch',
'method-head': method === 'head',
- 'method-options': method == 'options'
+ 'method-options': method === 'options',
+ 'method-grpc': isGrpc,
});
};
@@ -24,7 +27,7 @@ const RequestMethod = ({ item }) => {
- {item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method}
+ {isGrpc ? 'grpc' : item.request.method.length > 5 ? item.request.method.substring(0, 3) : item.request.method}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js
index 3b6e08f42..e7dd94d2f 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/StyledWrapper.js
@@ -4,6 +4,9 @@ const Wrapper = styled.div`
.bruno-modal-content {
padding-bottom: 1rem;
}
+ .warning {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
`;
export default Wrapper;
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js
index 4a81f59af..a3cbbd0fa 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/RunCollectionItem/index.js
@@ -2,15 +2,27 @@ import React from 'react';
import get from 'lodash/get';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
+import { areItemsLoading } from 'utils/collections';
+import RunnerTags from 'components/RunnerResults/RunnerTags/index';
+import { getRequestItemsForCollectionRun } from 'utils/collections/index';
-const RunCollectionItem = ({ collection, item, onClose }) => {
+const RunCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
+ const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
+ const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
+
+ // tags for the collection run
+ const tags = get(collection, 'runnerTags', { include: [], exclude: [] });
+
+ // have tags been enabled for the collection run
+ const tagsEnabled = get(collection, 'runnerTagsEnabled', false);
+
const onSubmit = (recursive) => {
dispatch(
addTab({
@@ -19,57 +31,83 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
type: 'collection-runner'
})
);
- dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
+ if (!isCollectionRunInProgress) {
+ dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive, 0, tagsEnabled && tags));
+ }
onClose();
};
- const getRequestsCount = (items) => {
- const requestTypes = ['http-request', 'graphql-request']
- return items.filter(req => requestTypes.includes(req.type)).length;
+ const handleViewRunner = (e) => {
+ e.preventDefault();
+ dispatch(
+ addTab({
+ uid: uuid(),
+ collectionUid: collection.uid,
+ type: 'collection-runner'
+ })
+ );
+ onClose();
}
- const runLength = item ? getRequestsCount(item.items) : get(collection, 'items.length', 0);
- const flattenedItems = flattenItems(item ? item.items : collection.items);
- const recursiveRunLength = getRequestsCount(flattenedItems);
+ const isFolderLoading = areItemsLoading(item);
+
+ const requestItemsForRecursiveFolderRun = getRequestItemsForCollectionRun({ recursive: true, tags, items: item ? item.items : collection.items });
+ const totalRequestItemsCountForRecursiveFolderRun = requestItemsForRecursiveFolderRun.length;
+ const shouldDisableRecursiveFolderRun = totalRequestItemsCountForRecursiveFolderRun <= 0;
+
+ const requestItemsForFolderRun = getRequestItemsForCollectionRun({ recursive: false, tags, items: item ? item.items : collection.items });
+ const totalRequestItemsCountForFolderRun = requestItemsForFolderRun.length;
+ const shouldDisableFolderRun = totalRequestItemsCountForFolderRun <= 0;
return (
- {!runLength && !recursiveRunLength ? (
- No request found in this folder.
- ) : (
-
-
- Run
- ({runLength} requests)
-
-
This will only run the requests in this folder.
-
-
- Recursive Run
- ({recursiveRunLength} requests)
-
-
This will run all the requests in this folder and all its subfolders.
-
-
-
-
- Cancel
-
-
-
- onSubmit(true)}>
- Recursive Run
-
-
-
- onSubmit(false)}>
- Run
-
-
-
+
+
+ Run
+ ({totalRequestItemsCountForFolderRun} requests)
- )}
+
This will only run the requests in this folder.
+
+ Recursive Run
+ ({totalRequestItemsCountForRecursiveFolderRun} requests)
+
+
This will run all the requests in this folder and all its subfolders.
+ {isFolderLoading ?
Requests in this folder are still loading.
: null}
+ {isCollectionRunInProgress ?
A Collection Run is already in progress.
: null}
+
+ {/* Tags for the collection run */}
+
+
+
+
+
+ Cancel
+
+
+ {
+ isCollectionRunInProgress ?
+
+
+ View Run
+
+
+ :
+ <>
+
+ onSubmit(true)}>
+ Recursive Run
+
+
+
+ onSubmit(false)}>
+ Run
+
+
+ >
+ }
+
+
);
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
index 8d61203e1..4e3525df9 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/StyledWrapper.js
@@ -1,6 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
+ position: relative;
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
@@ -22,6 +23,65 @@ const Wrapper = styled.div`
height: 1.875rem;
cursor: pointer;
user-select: none;
+ position: relative;
+
+ /* Common styles for drop indicators */
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: ${(props) => props.theme.dragAndDrop.border};
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ &::before {
+ top: 0;
+ }
+
+ &::after {
+ bottom: 0;
+ }
+
+ /* Drop target styles */
+ &.drop-target {
+ background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
+
+ &::before,
+ &::after {
+ opacity: 0;
+ }
+ }
+
+ &.drop-target-above {
+ &::before {
+ opacity: 1;
+ height: 2px;
+ }
+ }
+
+ &.drop-target-below {
+ &::after {
+ opacity: 1;
+ height: 2px;
+ }
+ }
+
+ /* Inside drop target style */
+ &.drop-target {
+ &::before {
+ top: 0;
+ bottom: 0;
+ height: 100%;
+ opacity: 1;
+ background: ${(props) => props.theme.dragAndDrop.hoverBg};
+ border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ // border-radius: 4px;
+ }
+ }
.rotate-90 {
transform: rotateZ(90deg);
@@ -45,6 +105,20 @@ const Wrapper = styled.div`
}
}
+ &.item-target {
+ background: #ccc3;
+ }
+
+ &.item-seperator {
+ .seperator {
+ bottom: 0px;
+ position: absolute;
+ height: 3px;
+ width: 100%;
+ background: #ccc3;
+ }
+ }
+
&.item-focused-in-tab {
background: ${(props) => props.theme.sidebar.collection.item.bg};
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
index ed402825d..e7a3a21ea 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js
@@ -1,37 +1,50 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
+import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
import classnames from 'classnames';
import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
-import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
-import { moveItem, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
-import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
+import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
+import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
+import { toggleCollectionItem } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
-import RequestMethod from './RequestMethod';
import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
import RunCollectionItem from './RunCollectionItem';
import GenerateCodeItem from './GenerateCodeItem';
-import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
+import { isItemARequest, isItemAFolder } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import NetworkError from 'components/ResponsePane/NetworkError/index';
-import { uuid } from 'utils/common';
+import CollectionItemInfo from './CollectionItemInfo/index';
+import CollectionItemIcon from './CollectionItemIcon';
+import { scrollToTheActiveTab } from 'utils/tabs';
+import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
+import { isEqual } from 'lodash';
+import { calculateDraggedItemNewPathname } from 'utils/collections/index';
+import { sortByNameThenSequence } from 'utils/common/index';
-const CollectionItem = ({ item, collection, searchText }) => {
- const tabs = useSelector((state) => state.tabs.tabs);
- const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
+const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
+ const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
+ const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
+
+ const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid });
+ const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
+
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch();
+ // We use a single ref for drag and drop.
+ const ref = useRef(null);
+
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
@@ -39,38 +52,89 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
- const [itemIsCollapsed, setItemisCollapsed] = useState(item.collapsed);
+ const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
+ const hasSearchText = searchText && searchText?.trim()?.length;
+ const itemIsCollapsed = hasSearchText ? false : item.collapsed;
+ const isFolder = isItemAFolder(item);
- const [{ isDragging }, drag] = useDrag({
- type: `COLLECTION_ITEM_${collection.uid}`,
- item: item,
+ const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
+
+ const [{ isDragging }, drag, dragPreview] = useDrag({
+ type: `collection-item-${collectionUid}`,
+ item,
collect: (monitor) => ({
isDragging: monitor.isDragging()
- })
- });
-
- const [{ isOver }, drop] = useDrop({
- accept: `COLLECTION_ITEM_${collection.uid}`,
- drop: (draggedItem) => {
- if (draggedItem.uid !== item.uid) {
- dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
- }
- },
- canDrop: (draggedItem) => {
- return draggedItem.uid !== item.uid;
- },
- collect: (monitor) => ({
- isOver: monitor.isOver()
- })
+ }),
+ options: {
+ dropEffect: "move"
+ }
});
useEffect(() => {
- if (searchText && searchText.length) {
- setItemisCollapsed(false);
+ dragPreview(getEmptyImage(), { captureDraggingState: true });
+ }, []);
+
+ const determineDropType = (monitor) => {
+ const hoverBoundingRect = ref.current?.getBoundingClientRect();
+ const clientOffset = monitor.getClientOffset();
+ if (!hoverBoundingRect || !clientOffset) return null;
+
+ const clientY = clientOffset.y - hoverBoundingRect.top;
+ const folderUpperThreshold = hoverBoundingRect.height * 0.35;
+ const fileUpperThreshold = hoverBoundingRect.height * 0.5;
+
+ if (isItemAFolder(item)) {
+ return clientY < folderUpperThreshold ? 'adjacent' : 'inside';
} else {
- setItemisCollapsed(item.collapsed);
+ return clientY < fileUpperThreshold ? 'adjacent' : null;
}
- }, [searchText, item]);
+ };
+
+ const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
+ const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
+ const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
+
+ if (draggedItemUid === targetItemUid) return false;
+
+ const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
+ if (!newPathname) return false;
+
+ if (targetItemPathname?.startsWith(draggedItemPathname)) return false;
+
+ return true;
+ };
+
+ const [{ isOver, canDrop }, drop] = useDrop({
+ accept: `collection-item-${collectionUid}`,
+ hover: (draggedItem, monitor) => {
+ const { uid: targetItemUid } = item;
+ const { uid: draggedItemUid } = draggedItem;
+
+ if (draggedItemUid === targetItemUid) return;
+
+ const dropType = determineDropType(monitor);
+
+ const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType });
+
+ setDropType(_canItemBeDropped ? dropType : null);
+ },
+ drop: async (draggedItem, monitor) => {
+ const { uid: targetItemUid } = item;
+ const { uid: draggedItemUid } = draggedItem;
+
+ if (draggedItemUid === targetItemUid) return;
+
+ const dropType = determineDropType(monitor);
+ if (!dropType) return;
+
+ await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }))
+ setDropType(null);
+ },
+ canDrop: (draggedItem) => draggedItem.uid !== item.uid,
+ collect: (monitor) => ({
+ isOver: monitor.isOver()
+ }),
+ });
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
@@ -85,20 +149,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
'rotate-90': !itemIsCollapsed
});
- const itemRowClassName = classnames('flex collection-item-name items-center', {
- 'item-focused-in-tab': item.uid == activeTabUid,
- 'item-hovered': isOver
+ const itemRowClassName = classnames('flex collection-item-name relative items-center', {
+ 'item-focused-in-tab': isTabForItemActive,
+ 'item-hovered': isOver && canDrop,
+ 'drop-target': isOver && dropType === 'inside',
+ 'drop-target-above': isOver && dropType === 'adjacent'
});
- const scrollToTheActiveTab = () => {
- const activeTab = document.querySelector('.request-tab.active');
- if (activeTab) {
- activeTab.scrollIntoView({ behavior: 'smooth', block: 'start' });
- }
- };
-
const handleRun = async () => {
- dispatch(sendRequest(item, collection.uid)).catch((err) =>
+ dispatch(sendRequest(item, collectionUid)).catch((err) =>
toast.custom((t) =>
toast.dismiss(t.id)} />, {
duration: 5000
})
@@ -106,12 +165,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
};
const handleClick = (event) => {
+ if (event && event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
-
- if (isItemARequest(item)) {
+ const isRequest = isItemARequest(item);
+ if (isRequest) {
dispatch(hideHomePage());
- if (itemIsOpenedInTabs(item, tabs)) {
+ if (isTabForItemPresent) {
dispatch(
focusTab({
uid: item.uid
@@ -122,35 +182,46 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
addTab({
uid: item.uid,
- collectionUid: collection.uid,
- requestPaneTab: getDefaultRequestPaneTab(item)
+ collectionUid: collectionUid,
+ requestPaneTab: getDefaultRequestPaneTab(item),
+ type: 'request',
})
);
- return;
- }
+ } else {
dispatch(
addTab({
uid: item.uid,
- collectionUid: collection.uid,
- type: 'folder-settings'
- })
- );
- dispatch(
- collectionFolderClicked({
- itemUid: item.uid,
- collectionUid: collection.uid
+ collectionUid: collectionUid,
+ type: 'folder-settings',
})
);
+ if(item.collapsed) {
+ dispatch(
+ toggleCollectionItem({
+ itemUid: item.uid,
+ collectionUid: collectionUid
+ })
+ );
+ }
+ }
};
- const handleFolderCollapse = () => {
+ const handleFolderCollapse = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
dispatch(
- collectionFolderClicked({
+ toggleCollectionItem({
itemUid: item.uid,
- collectionUid: collection.uid
+ collectionUid: collectionUid
})
);
- }
+ };
+
+ // prevent the parent's double-click handler from firing
+ const handleFolderDoubleClick = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ };
const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current;
@@ -163,13 +234,8 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
};
- const handleDoubleClick = (event) => {
- setRenameItemModalOpen(true);
- };
-
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
- const isFolder = isItemAFolder(item);
const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging
@@ -187,19 +253,32 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
}
- // we need to sort request items by seq property
- const sortRequestItems = (items = []) => {
+ const handleDoubleClick = (event) => {
+ dispatch(makeTabPermanent({ uid: item.uid }));
+ };
+
+ // Sort items by their "seq" property.
+ const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
- // we need to sort folder items by name alphabetically
- const sortFolderItems = (items = []) => {
- return items.sort((a, b) => a.name.localeCompare(b.name));
+ const handleShowInFolder = () => {
+ dispatch(showInFolder(item.pathname)).catch((error) => {
+ console.error('Error opening the folder', error);
+ toast.error('Error opening the folder');
+ });
};
+
+ const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i)));
+ const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
+
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
- if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) {
+ if (
+ (item?.request?.url !== '') ||
+ (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
+ ) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
@@ -208,78 +287,74 @@ const CollectionItem = ({ item, collection, searchText }) => {
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
- if (itemIsOpenedInTabs(item, tabs)) {
- dispatch(
- focusTab({
- uid: item.uid
- })
- );
+ if (isTabForItemPresent) {
+ dispatch(focusTab({ uid: item.uid }));
return;
}
dispatch(
addTab({
uid: item.uid,
- collectionUid: collection.uid,
+ collectionUid,
type: 'folder-settings'
})
);
- return;
}
};
- const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
- const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
-
return (
{renameItemModalOpen && (
- setRenameItemModalOpen(false)} />
+ setRenameItemModalOpen(false)} />
)}
{cloneItemModalOpen && (
- setCloneItemModalOpen(false)} />
+ setCloneItemModalOpen(false)} />
)}
{deleteItemModalOpen && (
- setDeleteItemModalOpen(false)} />
+ setDeleteItemModalOpen(false)} />
)}
{newRequestModalOpen && (
- setNewRequestModalOpen(false)} />
+ setNewRequestModalOpen(false)} />
)}
{newFolderModalOpen && (
- setNewFolderModalOpen(false)} />
+ setNewFolderModalOpen(false)} />
)}
{runCollectionModalOpen && (
- setRunCollectionModalOpen(false)} />
+ setRunCollectionModalOpen(false)} />
)}
{generateCodeItemModalOpen && (
- setGenerateCodeItemModalOpen(false)} />
+ setGenerateCodeItemModalOpen(false)} />
)}
- drag(drop(node))}>
+ {itemInfoModalOpen && (
+
setItemInfoModalOpen(false)} />
+ )}
+ {
+ ref.current = node;
+ drag(drop(node));
+ }}
+ >
{indents && indents.length
- ? indents.map((i) => {
- return (
-
- {/* Indent */}
-
- );
- })
+ ? indents.map((i) => (
+
+ {/* Indent */}
+
+ ))
: null}
{isFolder ? (
@@ -289,17 +364,12 @@ const CollectionItem = ({ item, collection, searchText }) => {
className={iconClassName}
style={{ color: 'rgb(160 160 160)' }}
onClick={handleFolderCollapse}
+ onDoubleClick={handleFolderDoubleClick}
/>
) : null}
-
-
-
+
+
{item.name}
@@ -378,6 +448,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
Generate Code
)}
+
{
+ dropdownTippyRef.current.hide();
+ handleShowInFolder();
+ }}
+ >
+ Show in Folder
+
{
@@ -398,21 +477,29 @@ const CollectionItem = ({ item, collection, searchText }) => {
Settings
)}
+
{
+ dropdownTippyRef.current.hide();
+ setItemInfoModalOpen(true);
+ }}
+ >
+ Info
+
-
{!itemIsCollapsed ? (
{folderItems && folderItems.length
? folderItems.map((i) => {
- return ;
+ return ;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
- return ;
+ return ;
})
: null}
@@ -421,4 +508,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
-export default CollectionItem;
+export default React.memo(CollectionItem);
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js
index 92e252410..cdc400b65 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/ExportCollection/index.js
@@ -1,7 +1,6 @@
import React from 'react';
import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
-import { toastError } from 'utils/common/error';
import cloneDeep from 'lodash/cloneDeep';
import Modal from 'components/Modal';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js
index 9cba09179..17b6dc007 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RemoveCollection/index.js
@@ -1,12 +1,14 @@
import React from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { IconFiles } from '@tabler/icons';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
+import { findCollectionByUid } from 'utils/collections/index';
-const RemoveCollection = ({ onClose, collection }) => {
+const RemoveCollection = ({ onClose, collectionUid }) => {
const dispatch = useDispatch();
+ const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const onConfirm = () => {
dispatch(removeCollection(collection.uid))
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js
index a6e11051e..0d3a4c34a 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/RenameCollection/index.js
@@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
+import { findCollectionByUid } from 'utils/collections/index';
-const RenameCollection = ({ collection, onClose }) => {
+const RenameCollection = ({ collectionUid, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
+ const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const formik = useFormik({
enableReinitialize: true,
initialValues: {
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
index b8e0d21fd..8c1111c29 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/StyledWrapper.js
@@ -12,6 +12,18 @@ const Wrapper = styled.div`
transform: rotateZ(90deg);
}
+ &.item-hovered {
+ border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ border-bottom: 2px solid transparent;
+ .collection-actions {
+ .dropdown {
+ div[aria-expanded='false'] {
+ visibility: visible;
+ }
+ }
+ }
+ }
+
.collection-actions {
.dropdown {
div[aria-expanded='true'] {
@@ -29,6 +41,7 @@ const Wrapper = styled.div`
}
&:hover {
+ background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
.collection-actions {
.dropdown {
div[aria-expanded='false'] {
@@ -51,6 +64,44 @@ const Wrapper = styled.div`
color: white;
}
}
+
+ &.drop-target {
+ background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
+ transition: ${(props) => props.theme.dragAndDrop.transition};
+ }
+
+ &.drop-target-above {
+ border: none;
+ border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ margin-top: -2px;
+ background: transparent;
+ transition: ${(props) => props.theme.dragAndDrop.transition};
+ }
+
+ &.drop-target-below {
+ border: none;
+ border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ margin-bottom: -2px;
+ background: transparent;
+ transition: ${(props) => props.theme.dragAndDrop.transition};
+ }
+
+ &.collection-focused-in-tab {
+ background: ${(props) => props.theme.sidebar.collection.item.bg};
+
+ &:hover {
+ background: ${(props) => props.theme.sidebar.collection.item.bg} !important;
+ }
+ }
+ }
+
+ .collection-name.drop-target {
+ border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
+ border-radius: 4px;
+ background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
+ margin: -2px;
+ transition: ${(props) => props.theme.dragAndDrop.transition};
+ box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
}
#sidebar-collection-name {
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
index 3b814a7e5..0f44b467a 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js
@@ -1,37 +1,45 @@
import React, { useState, forwardRef, useRef, useEffect } from 'react';
+import { getEmptyImage } from 'react-dnd-html5-backend';
import classnames from 'classnames';
import { uuid } from 'utils/common';
import filter from 'lodash/filter';
-import { useDrop } from 'react-dnd';
-import { IconChevronRight, IconDots } from '@tabler/icons';
+import { useDrop, useDrag } from 'react-dnd';
+import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
-import { collectionClicked } from 'providers/ReduxStore/slices/collections';
-import { moveItemToRootOfCollection } from 'providers/ReduxStore/slices/collections/actions';
-import { useDispatch } from 'react-redux';
-import { addTab } from 'providers/ReduxStore/slices/tabs';
+import { toggleCollection } from 'providers/ReduxStore/slices/collections';
+import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
+import { useDispatch, useSelector } from 'react-redux';
+import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem';
import RemoveCollection from './RemoveCollection';
-import ExportCollection from './ExportCollection';
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
-import { isItemAFolder, isItemARequest, transformCollectionToSaveToExportAsFile } from 'utils/collections';
-import exportCollection from 'utils/collections/export';
+import { isItemAFolder, isItemARequest } from 'utils/collections';
+import { isTabForItemActive } from 'src/selectors/tab';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
-import CloneCollection from './CloneCollection/index';
+import CloneCollection from './CloneCollection';
+import { areItemsLoading } from 'utils/collections';
+import { scrollToTheActiveTab } from 'utils/tabs';
+import ShareCollection from 'components/ShareCollection/index';
+import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
+import { sortByNameThenSequence } from 'utils/common/index';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
- const [showExportCollectionModal, setShowExportCollectionModal] = useState(false);
+ const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
- const [collectionIsCollapsed, setCollectionIsCollapsed] = useState(collection.collapsed);
const dispatch = useDispatch();
-
+ const isLoading = areItemsLoading(collection);
+ const collectionRef = useRef(null);
+
+ const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
+
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((props, ref) => {
@@ -52,33 +60,64 @@ const Collection = ({ collection, searchText }) => {
);
};
- useEffect(() => {
- if (searchText && searchText.length) {
- setCollectionIsCollapsed(false);
- } else {
- setCollectionIsCollapsed(collection.collapsed);
+ const ensureCollectionIsMounted = () => {
+ if(collection.mountStatus === 'mounted'){
+ return;
}
- }, [searchText, collection]);
+ dispatch(mountCollection({
+ collectionUid: collection.uid,
+ collectionPathname: collection.pathname,
+ brunoConfig: collection.brunoConfig
+ }));
+ }
+
+ const hasSearchText = searchText && searchText?.trim()?.length;
+ const collectionIsCollapsed = hasSearchText ? false : collection.collapsed;
const iconClassName = classnames({
'rotate-90': !collectionIsCollapsed
});
const handleClick = (event) => {
- dispatch(collectionClicked(collection.uid));
+ if (event.detail != 1) return;
+ // Check if the click came from the chevron icon
+ const isChevronClick = event.target.closest('svg')?.classList.contains('chevron-icon');
+ setTimeout(scrollToTheActiveTab, 50);
+
+ ensureCollectionIsMounted();
+
+ if(collection.collapsed) {
+ dispatch(toggleCollection(collection.uid));
+ }
+
+ if(!isChevronClick) {
+ dispatch(
+ addTab({
+ uid: collection.uid,
+ collectionUid: collection.uid,
+ type: 'collection-settings',
+ })
+ );
+ }
};
- const handleCollapseCollection = () => {
- dispatch(collectionClicked(collection.uid));
- dispatch(
- addTab({
- uid: uuid(),
- collectionUid: collection.uid,
- type: 'collection-settings'
- })
- );
+ const handleDoubleClick = (event) => {
+ dispatch(makeTabPermanent({ uid: collection.uid }))
+ };
+
+ const handleCollectionCollapse = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ ensureCollectionIsMounted();
+ dispatch(toggleCollection(collection.uid));
}
+ // prevent the parent's double-click handler from firing
+ const handleCollectionDoubleClick = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ };
+
const handleRightClick = (event) => {
const _menuDropdown = menuDropdownTippyRef.current;
if (_menuDropdown) {
@@ -93,78 +132,110 @@ const Collection = ({ collection, searchText }) => {
const viewCollectionSettings = () => {
dispatch(
addTab({
- uid: uuid(),
+ uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
};
+ const isCollectionItem = (itemType) => {
+ return itemType.startsWith('collection-item');
+ };
+
+ const [{ isDragging }, drag, dragPreview] = useDrag({
+ type: "collection",
+ item: collection,
+ collect: (monitor) => ({
+ isDragging: monitor.isDragging(),
+ }),
+ options: {
+ dropEffect: "move"
+ }
+ });
+
const [{ isOver }, drop] = useDrop({
- accept: `COLLECTION_ITEM_${collection.uid}`,
- drop: (draggedItem) => {
- dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid));
+ accept: ["collection", `collection-item-${collection.uid}`],
+ drop: (draggedItem, monitor) => {
+ const itemType = monitor.getItemType();
+ if (isCollectionItem(itemType)) {
+ dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
+ } else {
+ dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
+ }
},
canDrop: (draggedItem) => {
- // todo need to make sure that draggedItem belongs to the collection
- return true;
+ return draggedItem.uid !== collection.uid;
},
collect: (monitor) => ({
- isOver: monitor.isOver()
- })
+ isOver: monitor.isOver(),
+ }),
});
+ useEffect(() => {
+ dragPreview(getEmptyImage(), { captureDraggingState: true });
+ }, []);
+
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
return null;
}
}
+ const collectionRowClassName = classnames('flex py-1 collection-name items-center', {
+ 'item-hovered': isOver,
+ 'collection-focused-in-tab': isCollectionFocused
+ });
+
// we need to sort request items by seq property
- const sortRequestItems = (items = []) => {
+ const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
- // we need to sort folder items by name alphabetically
- const sortFolderItems = (items = []) => {
- return items.sort((a, b) => a.name.localeCompare(b.name));
- };
-
- const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
- const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
+ const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
+ const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i)));
return (
- {showNewRequestModal && setShowNewRequestModal(false)} />}
- {showNewFolderModal && setShowNewFolderModal(false)} />}
+ {showNewRequestModal && setShowNewRequestModal(false)} />}
+ {showNewFolderModal && setShowNewFolderModal(false)} />}
{showRenameCollectionModal && (
- setShowRenameCollectionModal(false)} />
+ setShowRenameCollectionModal(false)} />
)}
{showRemoveCollectionModal && (
- setShowRemoveCollectionModal(false)} />
+ setShowRemoveCollectionModal(false)} />
)}
- {showExportCollectionModal && (
- setShowExportCollectionModal(false)} />
+ {showShareCollectionModal && (
+ setShowShareCollectionModal(false)} />
)}
{showCloneCollectionModalOpen && (
- setShowCloneCollectionModalOpen(false)} />
+ setShowCloneCollectionModalOpen(false)} />
)}
-
+
+
{
+ collectionRef.current = node;
+ drag(drop(node));
+ }}
+ >
-
} placement="bottom-start">
@@ -199,6 +270,7 @@ const Collection = ({ collection, searchText }) => {
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
+ ensureCollectionIsMounted();
handleRun();
}}
>
@@ -217,10 +289,11 @@ const Collection = ({ collection, searchText }) => {
className="dropdown-item"
onClick={(e) => {
menuDropdownTippyRef.current.hide();
- setShowExportCollectionModal(true);
+ ensureCollectionIsMounted();
+ setShowShareCollectionModal(true);
}}
>
- Export
+ Share
{
-
{!collectionIsCollapsed ? (
- {folderItems && folderItems.length
- ? folderItems.map((i) => {
- return ;
- })
- : null}
- {requestItems && requestItems.length
- ? requestItems.map((i) => {
- return ;
- })
- : null}
+ {folderItems?.map?.((i) => {
+ return ;
+ })}
+ {requestItems?.map?.((i) => {
+ return ;
+ })}
) : null}
diff --git a/packages/bruno-app/src/components/Sidebar/Collections/index.js b/packages/bruno-app/src/components/Sidebar/Collections/index.js
index 91018594f..119318819 100644
--- a/packages/bruno-app/src/components/Sidebar/Collections/index.js
+++ b/packages/bruno-app/src/components/Sidebar/Collections/index.js
@@ -8,12 +8,10 @@ import {
IconSortDescendingLetters,
IconX
} from '@tabler/icons';
-import Collection from '../Collections/Collection';
+import Collection from './Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
-import { DndProvider } from 'react-dnd';
-import { HTML5Backend } from 'react-dnd-html5-backend';
import { sortCollections } from 'providers/ReduxStore/slices/collections/actions';
// todo: move this to a separate folder
@@ -91,7 +89,7 @@ const Collections = () => {
{
)}
-
+
{collections && collections.length
? collections.map((c) => {
return (
-
-
-
+
);
})
: null}
diff --git a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
index 6f05207d2..3eb6707e0 100644
--- a/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/CreateCollection/index.js
@@ -5,12 +5,20 @@ import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { createCollection } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
-import InfoTip from 'components/InfoTip';
import Modal from 'components/Modal';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import PathDisplay from 'components/PathDisplay/index';
+import { useState } from 'react';
+import { IconArrowBackUp, IconEdit } from '@tabler/icons';
+import Help from 'components/Help';
+import { multiLineMsg } from "utils/common";
+import { formatIpcError } from "utils/common/error";
+import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
const dispatch = useDispatch();
+ const [isEditing, toggleEditing] = useState(false);
const formik = useFormik({
enableReinitialize: true,
@@ -22,12 +30,15 @@ const CreateCollection = ({ onClose }) => {
validationSchema: Yup.object({
collectionName: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
+ .max(255, 'must be 255 characters or less')
.required('collection name is required'),
collectionFolderName: Yup.string()
.min(1, 'must be at least 1 character')
- .max(50, 'must be 50 characters or less')
- .matches(/^[\w\-. ]+$/, 'Folder name contains invalid characters')
+ .max(255, 'must be 255 characters or less')
+ .test('is-valid-collection-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.required('folder name is required'),
collectionLocation: Yup.string().min(1, 'location is required').required('location is required')
}),
@@ -35,16 +46,17 @@ const CreateCollection = ({ onClose }) => {
dispatch(createCollection(values.collectionName, values.collectionFolderName, values.collectionLocation))
.then(() => {
toast.success('Collection created!');
+ dispatch(toggleSidebarCollapse());
onClose();
})
- .catch((e) => toast.error('An error occurred while creating the collection - ' + e));
+ .catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
- // When the user closes the diolog without selecting anything dirPath will be false
+ // When the user closes the dialog without selecting anything dirPath will be false
if (typeof dirPath === 'string') {
formik.setFieldValue('collectionLocation', dirPath);
}
@@ -78,9 +90,7 @@ const CreateCollection = ({ onClose }) => {
className="block textbox mt-2 w-full"
onChange={(e) => {
formik.handleChange(e);
- if (formik.values.collectionName === formik.values.collectionFolderName) {
- formik.setFieldValue('collectionFolderName', e.target.value);
- }
+ !isEditing && formik.setFieldValue('collectionFolderName', sanitizeName(e.target.value));
}}
autoComplete="off"
autoCorrect="off"
@@ -92,14 +102,21 @@ const CreateCollection = ({ onClose }) => {
{formik.errors.collectionName}
) : null}
-
+
Location
+
+
+ Bruno stores your collections on your computer's filesystem.
+
+
+ Choose the location where you want to store this collection.
+
+
{
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
+ onChange={e => {
+ formik.setFieldValue('collectionLocation', e.target.value);
+ }}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
{formik.errors.collectionLocation}
) : null}
-
+
Browse
-
-
- Folder Name
-
-
-
- {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
- {formik.errors.collectionFolderName}
- ) : null}
+ {formik.values.collectionName?.trim()?.length > 0 && (
+
+
+
+ Folder Name
+
+
+ The name of the folder used to store the collection.
+
+
+ You can choose a folder name different from your collection's name or one compatible with filesystem rules.
+
+
+
+ {isEditing ? (
+
toggleEditing(false)}
+ />
+ ) : (
+ toggleEditing(true)}
+ />
+ )}
+
+ {isEditing ? (
+
+ ) : (
+
+ )}
+ {formik.touched.collectionFolderName && formik.errors.collectionFolderName ? (
+
{formik.errors.collectionFolderName}
+ ) : null}
+
+ )}
diff --git a/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js b/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js
index ac6acee68..d238fd206 100644
--- a/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js
+++ b/packages/bruno-app/src/components/Sidebar/GoldenEdition/index.js
@@ -85,7 +85,7 @@ const GoldenEdition = ({ onClose }) => {
});
};
- const goldenEditonIndividuals = [
+ const goldenEditionIndividuals = [
'Inbuilt Bru File Explorer',
'Visual Git (Like Gitlens for Vscode)',
'GRPC, Websocket, SocketIO, MQTT',
@@ -97,7 +97,7 @@ const GoldenEdition = ({ onClose }) => {
'Custom Themes'
];
- const goldenEditonOrganizations = [
+ const goldenEditionOrganizations = [
'Centralized License Management',
'Integration with Secret Managers',
'Private Collection Registry',
@@ -179,7 +179,7 @@ const GoldenEdition = ({ onClose }) => {
{pricingOption === 'individuals' ? (
<>
- {goldenEditonIndividuals.map((item, index) => (
+ {goldenEditionIndividuals.map((item, index) => (
{item}
@@ -192,7 +192,7 @@ const GoldenEdition = ({ onClose }) => {
Everything in the Individual Plan
- {goldenEditonOrganizations.map((item, index) => (
+ {goldenEditionOrganizations.map((item, index) => (
{item}
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
index 47f0f553e..7d1c6599d 100644
--- a/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollection/index.js
@@ -1,106 +1,201 @@
-import React, { useState } from 'react';
-import importBrunoCollection from 'utils/importers/bruno-collection';
-import importPostmanCollection from 'utils/importers/postman-collection';
-import importInsomniaCollection from 'utils/importers/insomnia-collection';
-import importOpenapiCollection from 'utils/importers/openapi-collection';
+import React, { useState, useEffect, useRef } from 'react';
+import { IconLoader2, IconFileImport } from '@tabler/icons';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
+import jsyaml from 'js-yaml';
+import { postmanToBruno, isPostmanCollection } from 'utils/importers/postman-collection';
+import { convertInsomniaToBruno, isInsomniaCollection } from 'utils/importers/insomnia-collection';
+import { convertOpenapiToBruno, isOpenApiSpec } from 'utils/importers/openapi-collection';
+import { processBrunoCollection } from 'utils/importers/bruno-collection';
+
+const convertFileToObject = async (file) => {
+ const text = await file.text();
+
+ try {
+ if (file.type === 'application/json' || file.name.endsWith('.json')) {
+ return JSON.parse(text);
+ }
+
+ const parsed = jsyaml.load(text);
+ if (typeof parsed !== 'object' || parsed === null) {
+ throw new Error();
+ }
+ return parsed;
+ } catch {
+ throw new Error('Failed to parse the file – ensure it is valid JSON or YAML');
+ }
+};
+
+const FullscreenLoader = ({ isLoading }) => {
+ const [loadingMessage, setLoadingMessage] = useState('');
+
+ // Messages to cycle through while loading
+ const loadingMessages = [
+ 'Processing collection...',
+ 'Analyzing requests...',
+ 'Translating scripts...',
+ 'Preparing collection...',
+ 'Almost done...'
+ ];
+
+ useEffect(() => {
+ if (!isLoading) return;
+
+ let messageIndex = 0;
+ const interval = setInterval(() => {
+ messageIndex = (messageIndex + 1) % loadingMessages.length;
+ setLoadingMessage(loadingMessages[messageIndex]);
+ }, 2000);
+
+ setLoadingMessage(loadingMessages[0]);
+
+ return () => clearInterval(interval);
+ }, [isLoading]);
+
+ return (
+
+
+
+
+ {loadingMessage}
+
+
+ This may take a moment depending on the collection size
+
+
+
+ );
+};
const ImportCollection = ({ onClose, handleSubmit }) => {
- const [options, setOptions] = useState({
- enablePostmanTranslations: {
- enabled: true,
- label: 'Auto translate postman scripts',
- subLabel:
- "When enabled, Bruno will try as best to translate the scripts from the imported collection to Bruno's format."
+ const [isLoading, setIsLoading] = useState(false);
+ const [dragActive, setDragActive] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const handleDrag = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (e.dataTransfer) {
+ e.dataTransfer.dropEffect = 'copy';
+ }
+
+ if (e.type === 'dragenter' || e.type === 'dragover') {
+ setDragActive(true);
+ } else if (e.type === 'dragleave') {
+ setDragActive(false);
}
- });
- const handleImportBrunoCollection = () => {
- importBrunoCollection()
- .then(({ collection }) => {
- handleSubmit({ collection });
- })
- .catch((err) => toastError(err, 'Import collection failed'));
};
- const handleImportPostmanCollection = () => {
- importPostmanCollection(options)
- .then(({ collection, translationLog }) => {
- handleSubmit({ collection, translationLog });
- })
- .catch((err) => toastError(err, 'Postman Import collection failed'));
- };
-
- const handleImportInsomniaCollection = () => {
- importInsomniaCollection()
- .then(({ collection }) => {
- handleSubmit({ collection });
- })
- .catch((err) => toastError(err, 'Insomnia Import collection failed'));
- };
-
- const handleImportOpenapiCollection = () => {
- importOpenapiCollection()
- .then(({ collection }) => {
- handleSubmit({ collection });
- })
- .catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
- };
- const toggleOptions = (event, optionKey) => {
- setOptions({
- ...options,
- [optionKey]: {
- ...options[optionKey],
- enabled: !options[optionKey].enabled
+ const processFile = async (file) => {
+ setIsLoading(true);
+ try {
+ const data = await convertFileToObject(file);
+
+ if (!data) {
+ throw new Error('Failed to parse file content');
}
- });
+
+ let collection;
+
+ if (isPostmanCollection(data)) {
+ collection = await postmanToBruno(data);
+ }
+ else if (isInsomniaCollection(data)) {
+ collection = convertInsomniaToBruno(data);
+ }
+ else if (isOpenApiSpec(data)) {
+ collection = convertOpenapiToBruno(data);
+ }
+ else {
+ collection = await processBrunoCollection(data);
+ }
+
+ handleSubmit({ collection });
+ } catch (err) {
+ toastError(err, 'Import collection failed');
+ } finally {
+ setIsLoading(false);
+ }
};
- const CollectionButton = ({ children, className, onClick }) => {
- return (
-
- {children}
-
- );
+
+ const handleDrop = async (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setDragActive(false);
+
+ if (e.dataTransfer.files && e.dataTransfer.files[0]) {
+ await processFile(e.dataTransfer.files[0]);
+ }
};
+
+ const handleBrowseFiles = () => {
+ fileInputRef.current.click();
+ };
+
+ const handleFileInputChange = async (e) => {
+ if (e.target.files && e.target.files[0]) {
+ await processFile(e.target.files[0]);
+ }
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ const acceptedFileTypes = [
+ '.json',
+ '.yaml',
+ '.yml',
+ 'application/json',
+ 'application/yaml',
+ 'application/x-yaml'
+ ]
+
return (
-
Select the type of your existing collection :
-
- Bruno Collection
- Postman Collection
- Insomnia Collection
- OpenAPI V3 Spec
-
-
- {Object.entries(options || {}).map(([key, option]) => (
-
-
- toggleOptions(e, key)}
- className="h-3.5 w-3.5 rounded border-zinc-300 dark:ring-offset-zinc-800 bg-transparent text-indigo-600 dark:text-indigo-500 focus:ring-indigo-600 dark:focus:ring-indigo-500"
- />
-
-
-
- {option.label}
-
-
-
+
+
Import from file
+
+
+
+
+
+ Drop file to import or{' '}
+
+ choose a file
+
+
+
+ Supports Bruno, Postman, Insomnia, and OpenAPI v3 formats
+
- ))}
+
diff --git a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
index a58dd5b52..15410cbcd 100644
--- a/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
+++ b/packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js
@@ -4,105 +4,10 @@ import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
-import { IconAlertTriangle, IconArrowRight, IconCaretDown, IconCaretRight, IconCopy } from '@tabler/icons';
-import toast from 'react-hot-toast';
+import Help from 'components/Help';
-const TranslationLog = ({ translationLog }) => {
- const [showDetails, setShowDetails] = useState(false);
- const preventSetShowDetails = (e) => {
- e.stopPropagation();
- e.preventDefault();
- setShowDetails(!showDetails);
- };
- const copyClipboard = (e, value) => {
- e.stopPropagation();
- e.preventDefault();
- navigator.clipboard.writeText(value);
- toast.success('Copied to clipboard');
- };
- return (
-
-
-
-
-
-
-
-
- Warning: Some commands were not translated.{' '}
-
-
-
-
-
preventSetShowDetails(e)}
- className="flex w-fit items-center rounded px-2.5 py-1 mt-2 text-xs font-semibold ring-1 ring-inset bg-slate-50 dark:bg-slate-400/10 text-slate-700 dark:text-slate-300 ring-slate-600/10 dark:ring-slate-400/20"
- >
- See details
- {showDetails ? : }
-
- {showDetails && (
-
-
- Impacted Collections: {Object.keys(translationLog || {}).length}
-
-
- Impacted Lines:{' '}
- {Object.values(translationLog || {}).reduce(
- (acc, curr) => acc + (curr.script?.length || 0) + (curr.test?.length || 0),
- 0
- )}
-
-
- The numbers after 'script' and 'test' indicate the line numbers of incomplete translations.
-
-
- {Object.entries(translationLog || {}).map(([name, value]) => (
-
-
-
- {name}
-
-
- {value.script && (
-
- script :
- {value.script.map((scriptValue, index) => (
-
- {scriptValue}
- {index < value.script.length - 1 && <> - >}
-
- ))}
-
- )}
- {value.test && (
-
-
test :
- {value.test.map((testValue, index) => (
-
- {testValue}
- {index < value.test.length - 1 && <> - >}
-
- ))}
-
- )}
-
-
- ))}
-
-
copyClipboard(e, JSON.stringify(translationLog))}
- >
-
-
-
- )}
-
- );
-};
-const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, translationLog }) => {
+const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) => {
const inputRef = useRef();
const dispatch = useDispatch();
@@ -150,18 +55,22 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName, trans
Name
{collectionName}
- {translationLog && Object.keys(translationLog).length > 0 && (
-
- )}
<>
-
+
Location
+
+
+ Bruno stores your collections on your computer's filesystem.
+
+
+ Choose the location where you want to store this collection.
+
+
{
+ formik.setFieldValue('collectionLocation', e.target.value);
+ }}
/>
>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
diff --git a/packages/bruno-app/src/components/Sidebar/NewFolder/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/NewFolder/StyledWrapper.js
new file mode 100644
index 000000000..d46e186d2
--- /dev/null
+++ b/packages/bruno-app/src/components/Sidebar/NewFolder/StyledWrapper.js
@@ -0,0 +1,12 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.div`
+ .advanced-options {
+ .caret {
+ color: ${(props) => props.theme.textLink};
+ fill: ${(props) => props.theme.textLink};
+ }
+ }
+`;
+
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js
index ada38a1cb..83c243653 100644
--- a/packages/bruno-app/src/components/Sidebar/NewFolder/index.js
+++ b/packages/bruno-app/src/components/Sidebar/NewFolder/index.js
@@ -1,40 +1,61 @@
-import React, { useRef, useEffect } from 'react';
+import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useFormik } from 'formik';
import toast from 'react-hot-toast';
import * as Yup from 'yup';
+import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
+import { IconArrowBackUp, IconEdit} from '@tabler/icons';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
+import PathDisplay from 'components/PathDisplay/index';
+import Help from 'components/Help';
+import Dropdown from "components/Dropdown";
+import { IconCaretDown } from "@tabler/icons";
+import StyledWrapper from './StyledWrapper';
-const NewFolder = ({ collection, item, onClose }) => {
+const NewFolder = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
+ const [isEditing, toggleEditing] = useState(false);
+ const [showFilesystemName, toggleShowFilesystemName] = useState(false);
+
+ const dropdownTippyRef = useRef();
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+
const formik = useFormik({
enableReinitialize: true,
initialValues: {
- folderName: ''
+ folderName: '',
+ directoryName: ''
},
validationSchema: Yup.object({
folderName: Yup.string()
.trim()
.min(1, 'must be at least 1 character')
- .required('name is required')
+ .required('name is required'),
+ directoryName: Yup.string()
+ .trim()
+ .min(1, 'must be at least 1 character')
+ .required('foldername is required')
+ .test('is-valid-folder-name', function(value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
.test({
name: 'folderName',
message: 'The folder name "environments" at the root of the collection is reserved in bruno',
test: (value) => {
- if (item && item.uid) {
- return true;
- }
+ if (item?.uid) return true;
return value && !value.trim().toLowerCase().includes('environments');
}
})
}),
onSubmit: (values) => {
- dispatch(newFolder(values.folderName, collection.uid, item ? item.uid : null))
+ dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null))
.then(() => {
toast.success('New folder created!');
- onClose()
+ onClose();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the folder'));
}
@@ -46,34 +67,139 @@ const NewFolder = ({ collection, item, onClose }) => {
}
}, [inputRef]);
- const onSubmit = () => formik.handleSubmit();
+ const AdvancedOptions = forwardRef((props, ref) => {
+ return (
+
+
+ Options
+
+
+
+ );
+ });
return (
-
-
-
+
+
+
+
+
+
+
);
};
diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/StyledWrapper.js b/packages/bruno-app/src/components/Sidebar/NewRequest/StyledWrapper.js
index f7d7e914d..338fb2e60 100644
--- a/packages/bruno-app/src/components/Sidebar/NewRequest/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Sidebar/NewRequest/StyledWrapper.js
@@ -7,28 +7,23 @@ const StyledWrapper = styled.div`
background-color: ${(props) => props.theme.modal.input.bg};
border-top-left-radius: 3px;
border-bottom-left-radius: 3px;
-
.method-selector {
min-width: 80px;
}
}
-
div.method-selector-container,
div.input-container {
background-color: ${(props) => props.theme.modal.input.bg};
height: 2.3rem;
}
-
div.input-container {
border: solid 1px ${(props) => props.theme.modal.input.border};
border-top-right-radius: 3px;
border-bottom-right-radius: 3px;
-
input {
background-color: ${(props) => props.theme.modal.input.bg};
outline: none;
box-shadow: none;
-
&:focus {
outline: none !important;
box-shadow: none !important;
@@ -39,14 +34,20 @@ const StyledWrapper = styled.div`
textarea.curl-command {
min-height: 150px;
}
-
.dropdown {
width: fit-content;
- .dropdown-item {
- padding: 0.2rem 0.6rem !important;
+ .dropdown-item {
+ padding: 0.2rem 0.6rem !important;
+ }
+ }
+
+ .advanced-options {
+ .caret {
+ color: ${(props) => props.theme.textLink};
+ fill: ${(props) => props.theme.textLink};
}
}
`;
-export default StyledWrapper;
+export default StyledWrapper;
\ No newline at end of file
diff --git a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
index f95b3efcc..8a668409c 100644
--- a/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
+++ b/packages/bruno-app/src/components/Sidebar/NewRequest/index.js
@@ -2,34 +2,51 @@ import React, { useRef, useEffect, useCallback, forwardRef, useState } from 'rea
import { useFormik } from 'formik';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
+import path from 'utils/common/path';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
-import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
+import { newHttpRequest, newGrpcRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import HttpMethodSelector from 'components/RequestPane/QueryUrl/HttpMethodSelector';
import { getDefaultRequestPaneTab } from 'utils/collections';
-import StyledWrapper from './StyledWrapper';
import { getRequestFromCurlCommand } from 'utils/curl';
+import { IconArrowBackUp, IconCaretDown, IconEdit } from '@tabler/icons';
+import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import Dropdown from 'components/Dropdown';
-import { IconCaretDown } from '@tabler/icons';
+import PathDisplay from 'components/PathDisplay';
+import Portal from 'components/Portal';
+import Help from 'components/Help';
+import StyledWrapper from './StyledWrapper';
+import SingleLineEditor from 'components/SingleLineEditor/index';
+import { useTheme } from 'styled-components';
+import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
-const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
+const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
+
+ const storedTheme = useTheme();
+ const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
+
+ const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
const {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
const [curlRequestTypeDetected, setCurlRequestTypeDetected] = useState(null);
+ const [showFilesystemName, toggleShowFilesystemName] = useState(false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+ const advancedDropdownTippyRef = useRef();
+ const onAdvancedDropdownCreate = (ref) => (advancedDropdownTippyRef.current = ref);
+
const Icon = forwardRef((props, ref) => {
return (
- {curlRequestTypeDetected === 'http-request' ? "HTTP" : "GraphQL"}
+ {curlRequestTypeDetected === 'http-request' ? 'HTTP' : 'GraphQL'}
);
@@ -55,6 +72,8 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
setCurlRequestTypeDetected(type);
};
+ const [isEditing, toggleEditing] = useState(false);
+
const getRequestType = (collectionPresets) => {
if (!collectionPresets || !collectionPresets.requestType) {
return 'http-request';
@@ -72,6 +91,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
return 'graphql-request';
}
+ if (collectionPresets.requestType === 'grpc') {
+ // If gRPC is disabled in beta features, fall back to http-request
+ if (!isGrpcEnabled) {
+ return 'http-request';
+ }
+ return 'grpc-request';
+ }
+
return 'http-request';
};
@@ -79,6 +106,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
enableReinitialize: true,
initialValues: {
requestName: '',
+ filename: '',
requestType: getRequestType(collectionPresets),
requestUrl: collectionPresets.requestUrl || '',
requestMethod: 'GET',
@@ -88,15 +116,22 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestName: Yup.string()
.trim()
.min(1, 'must be at least 1 character')
- .required('name is required')
- .test({
- name: 'requestName',
- message: `The request names - collection and folder is reserved in bruno`,
- test: (value) => {
- const trimmedValue = value ? value.trim().toLowerCase() : '';
- return !['collection', 'folder'].includes(trimmedValue);
- }
- }),
+ .max(255, 'must be 255 characters or less')
+ .required('name is required'),
+ filename: Yup.string()
+ .trim()
+ .min(1, 'must be at least 1 character')
+ .max(255, 'must be 255 characters or less')
+ .required('filename is required')
+ .test('is-valid-filename', function (value) {
+ const isValid = validateName(value);
+ return isValid ? true : this.createError({ message: validateNameError(value) });
+ })
+ .test(
+ 'not-reserved',
+ `The file names "collection" and "folder" are reserved in bruno`,
+ (value) => !['collection', 'folder'].includes(value)
+ ),
curlCommand: Yup.string().when('requestType', {
is: (requestType) => requestType === 'from-curl',
then: Yup.string()
@@ -110,23 +145,44 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
})
}),
onSubmit: (values) => {
- if (isEphemeral) {
+ const isGrpcRequest = values.requestType === 'grpc-request';
+
+ if (isGrpcRequest) {
+ dispatch(
+ newGrpcRequest({
+ requestName: values.requestName,
+ filename: values.filename,
+ requestType: values.requestType,
+ requestUrl: values.requestUrl,
+ collectionUid: collection.uid,
+ itemUid: item ? item.uid : null
+ })
+ )
+ .then(() => {
+ toast.success('New request created!');
+ onClose();
+ })
+ .catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
+
+ // will need to handle import from grpcurl command when we support it, now it is just for creating new requests
+ } else if (isEphemeral) {
const uid = uuid();
dispatch(
newEphemeralHttpRequest({
uid: uid,
requestName: values.requestName,
+ filename: values.filename,
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
- collectionUid: collection.uid
+ collectionUid: collectionUid
})
)
.then(() => {
dispatch(
addTab({
uid: uid,
- collectionUid: collection.uid,
+ collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
})
);
@@ -135,38 +191,43 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else if (values.requestType === 'from-curl') {
const request = getRequestFromCurlCommand(values.curlCommand, curlRequestTypeDetected);
+ const settings = { encodeUrl: false };
+
dispatch(
newHttpRequest({
requestName: values.requestName,
+ filename: values.filename,
requestType: curlRequestTypeDetected,
requestUrl: request.url,
requestMethod: request.method,
- collectionUid: collection.uid,
+ collectionUid: collectionUid,
itemUid: item ? item.uid : null,
headers: request.headers,
body: request.body,
- auth: request.auth
+ auth: request.auth,
+ settings: settings
})
)
.then(() => {
toast.success('New request created!');
- onClose()
+ onClose();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
} else {
dispatch(
newHttpRequest({
requestName: values.requestName,
+ filename: values.filename,
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
- collectionUid: collection.uid,
+ collectionUid: collectionUid,
itemUid: item ? item.uid : null
})
)
.then(() => {
toast.success('New request created!');
- onClose()
+ onClose();
})
.catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}
@@ -218,160 +279,292 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
}
};
+ const AdvancedOptions = forwardRef((props, ref) => {
+ return (
+
+
+ Options
+
+
+
+ );
+ });
+
return (
-
-
-