Merge branch 'main' into feat/websocket-engine

This commit is contained in:
Sid
2025-09-30 17:19:41 +05:30
committed by GitHub
39 changed files with 987 additions and 43 deletions

View File

@@ -20,8 +20,8 @@ module.exports = runESMImports().then(() => defineConfig([
parser: require('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
sourceType: 'module'
}
},
files: [
'./eslint.config.js',
@@ -44,11 +44,11 @@ module.exports = runESMImports().then(() => defineConfig([
indent: 2,
quotes: 'single',
semi: true,
arrowParens: false,
jsx: true,
}).rules,
'@stylistic/comma-dangle': ['error', 'never'],
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }],
'@stylistic/arrow-parens': ['error', 'as-needed'],
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/curly-newline': ['error', {
multiline: true,
minElements: 2,
@@ -60,6 +60,7 @@ module.exports = runESMImports().then(() => defineConfig([
'@stylistic/function-call-spacing': ['error', 'never'],
'@stylistic/multiline-ternary': ['off'],
'@stylistic/padding-line-between-statements': ['off'],
'@stylistic/jsx-one-expression-per-line': ['off'],
'@stylistic/semi-style': ['error', 'last'],
'@stylistic/max-len': ['off'],
'@stylistic/jsx-one-expression-per-line': ['off'],

13
package-lock.json generated
View File

@@ -20286,6 +20286,18 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pidusage": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz",
"integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==",
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pify": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-5.0.0.tgz",
@@ -30248,6 +30260,7 @@
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"pidusage": "^4.0.1",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",

View File

@@ -12,7 +12,8 @@ import {
IconCode,
IconChevronDown,
IconTerminal2,
IconNetwork
IconNetwork,
IconDashboard,
} from '@tabler/icons';
import {
closeConsole,
@@ -24,10 +25,12 @@ import {
updateNetworkFilter,
toggleAllNetworkFilters
} from 'providers/ReduxStore/slices/logs';
import NetworkTab from './NetworkTab';
import RequestDetailsPanel from './RequestDetailsPanel';
// import DebugTab from './DebugTab';
import ErrorDetailsPanel from './ErrorDetailsPanel';
import Performance from '../Performance';
import StyledWrapper from './StyledWrapper';
const LogIcon = ({ type }) => {
@@ -384,6 +387,8 @@ const Console = () => {
);
case 'network':
return <NetworkTab />;
case 'performance':
return <Performance />;
// case 'debug':
// return <DebugTab />;
default:
@@ -484,6 +489,14 @@ const Console = () => {
<span>Network</span>
</button>
<button
className={`console-tab ${activeTab === 'performance' ? 'active' : ''}`}
onClick={() => handleTabChange('performance')}
>
<IconDashboard size={16} strokeWidth={1.5} />
<span>Performance</span>
</button>
{/* <button
className={`console-tab ${activeTab === 'debug' ? 'active' : ''}`}
onClick={() => handleTabChange('debug')}

View File

@@ -0,0 +1,120 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.tab-content {
height: 100%;
display: flex;
flex-direction: column;
background: ${props => props.theme.console.bg};
}
.tab-content-area {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.overview-container {
max-width: 1200px;
margin: 0 auto;
}
.overview-section {
margin-bottom: 32px;
&:last-child {
margin-bottom: 0;
}
}
.section-header {
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 1px solid ${props => props.theme.console.border};
h3 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: ${props => props.theme.console.titleColor};
}
p {
margin: 0;
font-size: 13px;
color: ${props => props.theme.console.textMuted};
}
}
.system-resources {
margin-bottom: 16px;
h2 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: ${props => props.theme.console.titleColor};
}
}
.resource-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.resource-card {
background: ${props => props.theme.console.headerBg};
border: 1px solid ${props => props.theme.console.border};
border-radius: 4px;
padding: 8px;
}
.resource-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
color: ${props => props.theme.console.titleColor};
}
.resource-title {
font-size: 12px;
font-weight: 500;
}
.resource-value {
font-size: 18px;
font-weight: 600;
color: ${props => props.theme.console.titleColor};
margin-bottom: 2px;
}
.resource-subtitle {
font-size: 11px;
color: ${props => props.theme.console.buttonColor};
}
.resource-trend {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
margin-top: 8px;
&.up {
color: #10b981;
}
&.down {
color: #e81123;
}
&.stable {
color: ${props => props.theme.console.buttonColor};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import {
IconCpu,
IconDatabase,
IconClock,
IconServer,
IconChartLine,
} from '@tabler/icons';
const Performance = () => {
const { systemResources } = useSelector(state => state.performance);
const formatBytes = bytes => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatUptime = seconds => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) return `${hours}h ${minutes}m ${secs}s`;
if (minutes > 0) return `${minutes}m ${secs}s`;
return `${secs}s`;
};
const SystemResourceCard = ({ icon: Icon, title, value, subtitle, color = 'default', trend }) => (
<div className={`resource-card ${color}`}>
<div className="resource-header">
<Icon size={20} strokeWidth={1.5} />
<span className="resource-title">{title}</span>
</div>
<div className="resource-value">{value}</div>
{subtitle && <div className="resource-subtitle">{subtitle}</div>}
{trend && (
<div className={`resource-trend ${trend > 0 ? 'up' : trend < 0 ? 'down' : 'stable'}`}>
<IconChartLine size={12} strokeWidth={1.5} />
<span>
{trend > 0 ? '+' : ''}
{trend.toFixed(1)}
%
</span>
</div>
)}
</div>
);
return (
<StyledWrapper>
<div className="tab-content">
<div className="tab-content-area">
<div className="system-resources">
<h2>System Resources</h2>
<div className="resource-cards">
<SystemResourceCard
icon={IconCpu}
title="CPU Usage"
value={`${systemResources.cpu.toFixed(1)}%`}
subtitle="Current process"
color={systemResources.cpu > 80 ? 'danger' : systemResources.cpu > 60 ? 'warning' : 'success'}
/>
<SystemResourceCard
icon={IconDatabase}
title="Memory Usage"
value={formatBytes(systemResources.memory)}
subtitle="Current process"
color={systemResources.memory > 500 * 1024 * 1024 ? 'danger' : 'default'}
/>
<SystemResourceCard
icon={IconClock}
title="Uptime"
value={formatUptime(systemResources.uptime)}
subtitle="Process runtime"
color="info"
/>
<SystemResourceCard
icon={IconServer}
title="Process ID"
value={systemResources.pid || 'N/A'}
subtitle="Current PID"
color="default"
/>
</div>
</div>
</div>
</div>
</StyledWrapper>
);
};
export default Performance;

View File

@@ -3,6 +3,7 @@ import get from 'lodash/get';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
@@ -35,7 +36,8 @@ const General = ({ close }) => {
})
.test('isValidTimeout', 'Request Timeout must be equal or greater than 0', (value) => {
return value === undefined || Number(value) >= 0;
})
}),
defaultCollectionLocation: Yup.string().max(1024)
});
const formik = useFormik({
@@ -50,7 +52,8 @@ const General = ({ close }) => {
},
timeout: preferences.request.timeout,
storeCookies: get(preferences, 'request.storeCookies', true),
sendCookies: get(preferences, 'request.sendCookies', true)
sendCookies: get(preferences, 'request.sendCookies', true),
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
},
validationSchema: preferencesSchema,
onSubmit: async (values) => {
@@ -79,6 +82,9 @@ const General = ({ close }) => {
timeout: newPreferences.timeout,
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies
},
general: {
defaultCollectionLocation: newPreferences.defaultCollectionLocation
}
}))
.then(() => {
@@ -99,6 +105,19 @@ const General = ({ close }) => {
formik.setFieldValue('customCaCertificate.filePath', null);
};
const browseDefaultLocation = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
formik.setFieldValue('defaultCollectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('defaultCollectionLocation', '');
console.error(error);
});
};
return (
<StyledWrapper>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
@@ -231,6 +250,35 @@ const General = ({ close }) => {
{formik.touched.timeout && formik.errors.timeout ? (
<div className="text-red-500">{formik.errors.timeout}</div>
) : null}
<div className="flex flex-col mt-6">
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
Default Collection Location
</label>
<input
type="text"
name="defaultCollectionLocation"
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.defaultCollectionLocation || ''}
onClick={browseDefaultLocation}
placeholder="Click to browse for default location"
/>
<div className="mt-1">
<span
className="text-link cursor-pointer hover:underline default-collection-location-browse"
onClick={browseDefaultLocation}
>
Browse
</span>
</div>
</div>
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
) : null}
<div className="mt-10">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save

View File

@@ -12,12 +12,15 @@ import PathDisplay from 'components/PathDisplay';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
import { findCollectionByUid } from 'utils/collections/index';
import get from 'lodash/get';
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 preferences = useSelector((state) => state.app.preferences);
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
const { name } = collection;
const formik = useFormik({
@@ -25,7 +28,7 @@ const CloneCollection = ({ onClose, collectionUid }) => {
initialValues: {
collectionName: `${name} copy`,
collectionFolderName: `${sanitizeName(name)} copy`,
collectionLocation: ''
collectionLocation: defaultLocation
},
validationSchema: Yup.object({
collectionName: Yup.string()

View File

@@ -6,7 +6,7 @@ import filter from 'lodash/filter';
import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { toggleCollection } from 'providers/ReduxStore/slices/collections';
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
@@ -132,6 +132,10 @@ const Collection = ({ collection, searchText }) => {
}
};
const handleCollapseFullCollection = () => {
dispatch(collapseFullCollection({ collectionUid: collection.uid }));
};
const viewCollectionSettings = () => {
dispatch(
addTab({
@@ -252,7 +256,7 @@ const Collection = ({ collection, searchText }) => {
</div>
{isLoading ? <IconLoader2 className="animate-spin mx-1" size={18} strokeWidth={1.5} /> : null}
</div>
<div className="collection-actions">
<div className="collection-actions" data-testid="collection-actions">
<Dropdown onCreate={onMenuDropdownCreate} icon={<MenuIcon />} placement="bottom-start">
<div
className="dropdown-item"
@@ -274,6 +278,7 @@ const Collection = ({ collection, searchText }) => {
</div>
<div
className="dropdown-item"
data-testid="clone-collection"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
setShowCloneCollectionModalOpen(true);
@@ -310,6 +315,15 @@ const Collection = ({ collection, searchText }) => {
>
Share
</div>
<div
className="dropdown-item"
onClick={(_e) => {
menuDropdownTippyRef.current.hide();
handleCollapseFullCollection();
}}
>
Collapse
</div>
<div
className="dropdown-item"
onClick={(_e) => {

View File

@@ -19,7 +19,10 @@ const CreateOrOpenCollection = () => {
const handleOpenCollection = () => {
dispatch(openCollection()).catch(
(err) => console.log(err) && toast.error('An error occurred while opening the collection')
(err) => {
console.log(err);
toast.error('An error occurred while opening the collection');
}
);
};
const CreateLink = () => (

View File

@@ -1,5 +1,5 @@
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';
@@ -14,18 +14,21 @@ import Help from 'components/Help';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import get from 'lodash/get';
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [isEditing, toggleEditing] = useState(false);
const preferences = useSelector((state) => state.app.preferences);
const defaultLocation = get(preferences, 'general.defaultCollectionLocation', '');
const formik = useFormik({
enableReinitialize: true,
initialValues: {
collectionName: '',
collectionFolderName: '',
collectionLocation: ''
collectionLocation: defaultLocation
},
validationSchema: Yup.object({
collectionName: Yup.string()

View File

@@ -55,7 +55,10 @@ const TitleBar = () => {
const handleOpenCollection = () => {
dispatch(openCollection()).catch(
(err) => console.log(err) && toast.error('An error occurred while opening the collection')
(err) => {
console.log(err);
toast.error('An error occurred while opening the collection');
}
);
};

View File

@@ -82,7 +82,7 @@ const StatusBar = () => {
<ToolHint text="Preferences" toolhintId="Preferences" place="top-start" offset={10}>
<button
className="status-bar-button"
className="status-bar-button preferences-button"
data-trigger="preferences"
onClick={() => dispatch(showPreferences(true))}
tabIndex={0}

View File

@@ -78,7 +78,7 @@ const Welcome = () => {
aria-label={t('WELCOME.CREATE_COLLECTION')}
>
<IconPlus aria-hidden size={18} strokeWidth={2} />
<span className="label ml-2" id="create-collection">
<span className="label ml-2" id="create-collection" data-testid="create-collection">
{t('WELCOME.CREATE_COLLECTION')}
</span>
</button>

View File

@@ -26,6 +26,7 @@ import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { addLog } from 'providers/ReduxStore/slices/logs';
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
const useIpcEvents = () => {
const dispatch = useDispatch();
@@ -145,6 +146,10 @@ const useIpcEvents = () => {
}));
});
const removeSystemResourcesListener = ipcRenderer.on('main:filesync-system-resources', resourceData => {
dispatch(updateSystemResources(resourceData));
});
const removeConfigUpdatesListener = ipcRenderer.on('main:bruno-config-update', (val) =>
dispatch(brunoConfigUpdateEvent(val))
);
@@ -209,6 +214,7 @@ const useIpcEvents = () => {
removeCollectionOauth2CredentialsUpdatesListener();
removeCollectionLoadingStateListener();
removePersistentEnvVariablesUpdateListener();
removeSystemResourcesListener();
};
}, [isElectron]);
};

View File

@@ -7,6 +7,7 @@ import tabsReducer from './slices/tabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
import logsReducer from './slices/logs';
import performanceReducer from './slices/performance';
import { draftDetectMiddleware } from './middlewares/draft/middleware';
const isDevEnv = () => {
@@ -25,7 +26,8 @@ export const store = configureStore({
tabs: tabsReducer,
notifications: notificationsReducer,
globalEnvironments: globalEnvironmentsReducer,
logs: logsReducer
logs: logsReducer,
performance: performanceReducer,
},
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware)
});

View File

@@ -25,6 +25,9 @@ const initialState = {
font: {
codeFont: 'default'
},
general: {
defaultCollectionLocation: ''
},
beta: {
grpc: false,
websocket: false,

View File

@@ -133,6 +133,13 @@ export const collectionsSlice = createSlice({
state.collections.push(collection);
}
},
collapseFullCollection: (state, action) => {
const { collectionUid } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
collapseAllItemsInCollection(collection);
}
},
updateCollectionMountStatus: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (collection) {
@@ -2931,6 +2938,7 @@ export const {
saveRequest,
deleteRequestDraft,
newEphemeralHttpRequest,
collapseFullCollection,
toggleCollection,
toggleCollectionItem,
requestUrlChanged,

View File

@@ -0,0 +1,28 @@
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
systemResources: {
cpu: 0,
memory: 0,
pid: null,
uptime: 0,
lastUpdated: null,
},
};
export const performanceSlice = createSlice({
name: 'performance',
initialState,
reducers: {
updateSystemResources: (state, action) => {
state.systemResources = {
...state.systemResources,
...action.payload,
lastUpdated: new Date().toISOString(),
};
},
},
});
export const { updateSystemResources } = performanceSlice.actions;
export default performanceSlice.reducer;

View File

@@ -12,31 +12,130 @@ let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const { get } = require('lodash');
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
const COPY_ICON_SVG_TEXT = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
`;
const renderVarInfo = (token, options, cm, pos) => {
// Extract variable name and value based on token
const { variableName, variableValue } = extractVariableInfo(token.string, options.variables);
const CHECKMARK_ICON_SVG_TEXT = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20,6 9,17 4,12"></polyline>
</svg>
`;
if (variableValue === undefined) {
const COPY_SUCCESS_COLOR = '#22c55e';
export const COPY_SUCCESS_TIMEOUT = 1000;
const getCopyButton = variableValue => {
const copyButton = document.createElement('button');
copyButton.className = 'copy-button';
copyButton.style.backgroundColor = 'transparent';
copyButton.style.border = 'none';
copyButton.style.color = 'inherit';
copyButton.style.cursor = 'pointer';
copyButton.style.padding = '2px';
copyButton.style.opacity = '0.7';
copyButton.style.transition = 'opacity 0.2s ease';
copyButton.style.display = 'flex';
copyButton.style.alignItems = 'center';
copyButton.style.justifyContent = 'center';
copyButton.innerHTML = COPY_ICON_SVG_TEXT;
let isCopied = false;
copyButton.addEventListener('mouseenter', () => {
if (isCopied) {
return;
}
const into = document.createElement('div');
const descriptionDiv = document.createElement('div');
descriptionDiv.className = 'info-description';
copyButton.style.opacity = '1';
});
if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
descriptionDiv.appendChild(document.createTextNode('*****'));
} else {
descriptionDiv.appendChild(document.createTextNode(variableValue));
copyButton.addEventListener('mouseleave', () => {
if (isCopied) {
return;
}
into.appendChild(descriptionDiv);
copyButton.style.opacity = '0.7';
});
return into;
};
copyButton.addEventListener('click', e => {
e.stopPropagation();
// Prevent clicking if showing success checkmark
if (isCopied) {
return;
}
navigator.clipboard
.writeText(variableValue)
.then(() => {
isCopied = true;
copyButton.innerHTML = CHECKMARK_ICON_SVG_TEXT;
copyButton.style.opacity = '1';
copyButton.style.color = COPY_SUCCESS_COLOR;
copyButton.style.cursor = 'default';
copyButton.classList.add('copy-success');
setTimeout(() => {
isCopied = false;
copyButton.innerHTML = COPY_ICON_SVG_TEXT;
copyButton.style.opacity = '0.7';
copyButton.style.color = 'inherit';
copyButton.style.cursor = 'pointer';
copyButton.classList.remove('copy-success');
}, COPY_SUCCESS_TIMEOUT);
})
.catch(err => {
console.error('Failed to copy to clipboard:', err.message);
});
});
return copyButton;
};
export const renderVarInfo = (token, options, cm, pos) => {
// Extract variable name and value based on token
const { variableName, variableValue } = extractVariableInfo(token.string, options.variables);
if (variableValue === undefined) {
return;
}
const into = document.createElement('div');
const contentDiv = document.createElement('div');
contentDiv.style.display = 'flex';
contentDiv.style.alignItems = 'center';
contentDiv.style.gap = '8px';
contentDiv.className = 'info-content';
const descriptionDiv = document.createElement('div');
descriptionDiv.className = 'info-description';
descriptionDiv.style.flex = '1';
if (options?.variables?.maskedEnvVariables?.includes(variableName)) {
descriptionDiv.appendChild(document.createTextNode('*****'));
} else {
descriptionDiv.appendChild(document.createTextNode(variableValue));
}
const copyButton = getCopyButton(variableValue);
contentDiv.appendChild(descriptionDiv);
contentDiv.appendChild(copyButton);
into.appendChild(contentDiv);
return into;
};
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
CodeMirror.defineOption('brunoVarInfo', false, function (cm, options, old) {
if (old && old !== CodeMirror.Init) {

View File

@@ -1,5 +1,5 @@
import { interpolate } from '@usebruno/common';
import { extractVariableInfo } from './brunoVarInfo';
import { COPY_SUCCESS_TIMEOUT, extractVariableInfo, renderVarInfo } from './brunoVarInfo';
// Mock the dependencies
jest.mock('@usebruno/common', () => ({
@@ -225,3 +225,120 @@ describe('extractVariableInfo', () => {
});
});
});
describe('renderVarInfo', () => {
let clipboardText = '';
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
// setup mock clipboard
clipboardText = '';
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: jest.fn(text => {
if (text === 'cause-clipboard-error') {
return Promise.reject(new Error('Clipboard error'));
}
clipboardText = text;
return Promise.resolve();
}),
},
configurable: true,
});
// mock console.error
console.error = jest.fn();
});
afterEach(() => {
jest.useRealTimers();
});
function setupRender(variables) {
const result = renderVarInfo({ string: '{{apiKey}}' }, { variables });
const contentDiv = result.querySelector('.info-content');
const descriptionDiv = contentDiv.querySelector('.info-description');
const copyButton = contentDiv.querySelector('.copy-button');
return { result, contentDiv, descriptionDiv, copyButton };
}
describe('popup functionality', () => {
it('should create a popup', () => {
const { result } = setupRender({ apiKey: 'test-value' });
expect(result).toBeDefined();
});
it('should create a popup with the correct variable name and value', () => {
const { descriptionDiv } = setupRender({ apiKey: 'test-value' });
expect(descriptionDiv.textContent).toBe('test-value');
});
it('should correctly mask the variable value in the popup', () => {
const { descriptionDiv } = setupRender({
apiKey: 'test-value',
maskedEnvVariables: ['apiKey'],
});
expect(descriptionDiv.textContent).toBe('*****');
});
});
describe('copy button functionality', () => {
it('should create a copy button', () => {
const { copyButton } = setupRender({ apiKey: 'test-value' });
expect(copyButton).toBeDefined();
});
it('should copy the variable value to the clipboard', async () => {
const { copyButton } = setupRender({ apiKey: 'test-value' });
await copyButton.click();
expect(clipboardText).toBe('test-value');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
});
it('should copy the variable value of masked variables to the clipboard', async () => {
const { copyButton } = setupRender({ apiKey: 'test-value', maskedEnvVariables: ['apiKey'] });
await copyButton.click();
expect(clipboardText).toBe('test-value');
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-value');
});
it('should show a success checkmark when the variable value is copied', async () => {
const { copyButton } = setupRender({ apiKey: 'test-value' });
expect(copyButton.classList.contains('copy-success')).toBe(false);
await copyButton.click();
expect(copyButton.classList.contains('copy-success')).toBe(true);
jest.advanceTimersByTime(COPY_SUCCESS_TIMEOUT);
expect(copyButton.classList.contains('copy-success')).toBe(false);
});
it('should log to the console when the variable value is not copied', async () => {
const { copyButton } = setupRender({ apiKey: 'cause-clipboard-error' });
await copyButton.click();
// wait for .catch() microtask to run
await Promise.resolve();
expect(clipboardText).toBe('');
expect(console.error).toHaveBeenCalledWith('Failed to copy to clipboard:', 'Clipboard error');
});
});
});

View File

@@ -66,6 +66,7 @@
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"pidusage": "^4.0.1",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",

View File

@@ -42,15 +42,36 @@ const getCollectionConfigFile = async (pathname) => {
};
const openCollectionDialog = async (win, watcher) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory']
const { canceled, filePaths } = await dialog.showOpenDialog(win, {
properties: ['openDirectory', 'createDirectory', 'multiSelections']
});
if (filePaths && filePaths[0]) {
const resolvedPath = path.resolve(filePaths[0]);
if (isDirectory(resolvedPath)) {
openCollection(win, watcher, resolvedPath);
} else {
console.error(`[ERROR] Cannot open unknown folder: "${resolvedPath}"`);
if (!canceled && filePaths?.length > 0) {
// Using Set to remove duplicates
const { openCollectionPromises, invalidPaths } = [...new Set(filePaths)].reduce((acc, filePath) => {
const resolvedPath = path.resolve(filePath);
if (isDirectory(resolvedPath)) {
// Open each valid collection in parallel
acc.openCollectionPromises.push(openCollection(win, watcher, resolvedPath).catch((err) => {
console.error(`[ERROR] Failed to open collection at "${resolvedPath}":`, err.message);
return { error: err, path: resolvedPath };
}));
} else {
acc.invalidPaths.push(resolvedPath);
console.error(`[ERROR] Cannot open unknown folder: "${resolvedPath}"`);
}
return acc;
},
{ openCollectionPromises: [], invalidPaths: [] });
// Wait for all valid collections to be opened
await Promise.all(openCollectionPromises);
// Notify about any invalid paths
if (invalidPaths.length > 0) {
win.webContents.send('main:display-error', `Some selected folders could not be opened: ${invalidPaths.join(', ')}`);
}
}
};
@@ -78,7 +99,7 @@ const openCollection = async (win, watcher, collectionPath, options = {}) => {
} catch (err) {
if (!options.dontSendDisplayErrors) {
win.webContents.send('main:display-error', {
error: err.message || 'An error occurred while opening the local collection'
message: err.message || 'An error occurred while opening the local collection'
});
}
}

View File

@@ -0,0 +1,71 @@
const pidusage = require('pidusage');
class SystemMonitor {
constructor() {
this.intervalId = null;
this.isMonitoring = false;
this.startTime = Date.now();
}
start(win, intervalMs = 2000) {
if (this.isMonitoring) {
return;
}
this.isMonitoring = true;
this.startTime = Date.now();
// Emit initial stats
this.emitSystemStats(win);
// Set up periodic monitoring
this.intervalId = setInterval(() => {
this.emitSystemStats(win);
}, intervalMs);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.isMonitoring = false;
}
async emitSystemStats(win) {
try {
const pid = process.pid;
const stats = await pidusage(pid);
const uptime = (Date.now() - this.startTime) / 1000;
const systemResources = {
cpu: stats.cpu || 0,
memory: stats.memory || 0,
pid: pid,
uptime: uptime,
timestamp: new Date().toISOString(),
};
win.webContents.send('main:filesync-system-resources', systemResources);
} catch (error) {
console.error('Error getting system stats:', error);
// Fallback stats if pidusage fails
const fallbackStats = {
cpu: 0,
memory: process.memoryUsage().rss,
pid: process.pid,
uptime: (Date.now() - this.startTime) / 1000,
timestamp: new Date().toISOString(),
};
win.webContents.send('main:filesync-system-resources', fallbackStats);
}
}
isRunning() {
return this.isMonitoring;
}
}
module.exports = SystemMonitor;

View File

@@ -45,8 +45,10 @@ const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
const { getDomainsWithCookies } = require('./utils/cookies');
const { cookiesStore } = require('./store/cookies');
const onboardUser = require('./app/onboarding');
const SystemMonitor = require('./app/system-monitor');
const lastOpenedCollections = new LastOpenedCollections();
const systemMonitor = new SystemMonitor();
// Reference: https://content-security-policy.com/
const contentSecurityPolicy = [
@@ -202,6 +204,9 @@ app.on('ready', async () => {
}
mainWindow.webContents.send('main:app-loaded');
// Start system monitoring for FileSync
systemMonitor.start(mainWindow);
});
// register all ipc handlers
@@ -220,6 +225,9 @@ app.on('before-quit', () => {
} catch (err) {
console.warn('Failed to flush cookies on quit', err);
}
// Stop system monitoring
systemMonitor.stop();
});
app.on('window-all-closed', app.quit);

View File

@@ -47,6 +47,9 @@ const defaultPreferences = {
},
onboarding: {
hasLaunchedBefore: false
},
general: {
defaultCollectionLocation: ''
}
};
@@ -89,6 +92,9 @@ const preferencesSchema = Yup.object().shape({
}),
onboarding: Yup.object({
hasLaunchedBefore: Yup.boolean()
}),
general: Yup.object({
defaultCollectionLocation: Yup.string().max(1024).nullable()
})
});

View File

@@ -1,6 +1,12 @@
import { test, expect } from '../../../playwright';
import { closeAllCollections } from '../../utils/page';
test.describe('Tag persistence', () => {
test.afterAll(async ({ pageWithUserData: page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('Verify tag persistence while moving requests within a collection', async ({ pageWithUserData: page, createTmpDir }) => {
// Create first collection - click dropdown menu first
await page.getByLabel('Create Collection').click();

View File

@@ -0,0 +1,108 @@
import { test, expect } from '../../../playwright';
import * as path from 'path';
import * as fs from 'fs';
import { closeAllCollections } from '../../utils/page';
test.describe('Open Multiple Collections', () => {
let originalShowOpenDialog;
test.beforeAll(async ({ electronApp }) => {
// save the original showOpenDialog function
await electronApp.evaluate(({ dialog }) => {
originalShowOpenDialog = dialog.showOpenDialog;
});
});
test.afterAll(async ({ electronApp }) => {
// restore the original showOpenDialog function
await electronApp.evaluate(({ dialog }) => {
dialog.showOpenDialog = originalShowOpenDialog;
});
});
test('Should open multiple collections using Open Collection feature', async ({
page,
electronApp,
createTmpDir
}) => {
// Create two test collections with proper bruno.json files
const collection1Dir = await createTmpDir('collection-1');
const collection2Dir = await createTmpDir('collection-2');
// Create bruno.json for first collection
const collection1Config = {
version: '1',
name: 'Test Collection 1',
type: 'collection'
};
// Create bruno.json for second collection
const collection2Config = {
version: '1',
name: 'Test Collection 2',
type: 'collection'
};
fs.writeFileSync(path.join(collection1Dir, 'bruno.json'), JSON.stringify(collection1Config, null, 2));
fs.writeFileSync(path.join(collection2Dir, 'bruno.json'), JSON.stringify(collection2Config, null, 2));
// Mock the electron dialog to return multiple folder selections
await electronApp.evaluate(({ dialog }, { collection1Dir, collection2Dir }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: [collection1Dir, collection2Dir]
});
},
{ collection1Dir, collection2Dir });
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
// Click on Open Collection(s) button
await page.getByRole('button', { name: 'Open Collection' }).click();
// Wait for both collections to appear in the sidebar
const collection1Element = page.locator('#sidebar-collection-name').getByText('Test Collection 1');
const collection2Element = page.locator('#sidebar-collection-name').getByText('Test Collection 2');
await expect(collection1Element).toBeVisible();
await expect(collection2Element).toBeVisible();
// cleanup: close all collections
await closeAllCollections(page);
});
test('Should handle invalid collection path and display error', async ({
page,
electronApp,
createTmpDir
}) => {
// Directory without bruno.json file
const collection1Dir = await createTmpDir('collection-1');
const collection2Dir = 'invalid-collection-path';
// Mock the electron dialog to return multiple folder selections
await electronApp.evaluate(({ dialog }, { collection1Dir, collection2Dir }) => {
dialog.showOpenDialog = async () => ({
canceled: false,
filePaths: [collection1Dir, collection2Dir]
});
},
{ collection1Dir, collection2Dir });
await expect(page.locator('#sidebar-collection-name').getByText('Test Collection 1')).not.toBeVisible();
// Click on Open Collection(s) button
await page.getByRole('button', { name: 'Open Collection' }).click();
// Verify no collections were opened
await expect(page.locator('#sidebar-collection-name')).toHaveCount(0);
// Verify invalid collection error
const invalidCollectionError = page.getByText('The collection is not valid (bruno.json not found)').first();
await expect(invalidCollectionError).toBeVisible();
// Verify invalid path error
const invalidPathError = page.getByText('Some selected folders could not be opened').getByText('invalid-collection-path').first();
await expect(invalidPathError).toBeVisible();
});
});

View File

@@ -0,0 +1,5 @@
{
"version": "1",
"name": "collection",
"type": "collection"
}

View File

@@ -0,0 +1,5 @@
meta {
name: collection
type: collection
version: 1.0.0
}

View File

@@ -0,0 +1,3 @@
vars {
host: https://www.httpfaker.org
}

View File

@@ -0,0 +1,11 @@
meta {
name: request
type: http
seq: 1
}
post {
url: {{host}}/api/echo
body: text
auth: none
}

View File

@@ -0,0 +1,84 @@
import { test, expect } from '../../../playwright';
test.describe('Default Collection Location Feature', () => {
test('Should hydrate the default location from preferences', async ({ pageWithUserData: page }) => {
// open preferences
await page.locator('.preferences-button').click();
// verify the default location is pre-filled
const defaultLocationInput = page.locator('.default-collection-location-input');
await expect(defaultLocationInput).toHaveValue('/tmp/bruno-collections');
// close the preferences
await page.locator('[data-test-id="modal-close-button"]').click();
// wait for 2 seconds
await page.waitForTimeout(2000);
});
test('Should save empty default location', async ({ pageWithUserData: page }) => {
// open preferences
await page.locator('.preferences-button').click();
// clear the default location field
const defaultLocationInput = page.locator('.default-collection-location-input');
await defaultLocationInput.clear();
// save preferences
await page.getByRole('button', { name: 'Save' }).click();
// verify success message
await expect(page.locator('text=Preferences saved successfully')).toBeVisible();
// wait for 2 seconds
await page.waitForTimeout(2000);
});
test('Should save a valid default location', async ({ pageWithUserData: page }) => {
// open preferences
await page.locator('.preferences-button').click();
// set a default location
const defaultLocationInput = page.locator('.default-collection-location-input');
// fill the default location input
await defaultLocationInput.fill('/tmp/bruno-collections');
// save preferences
await page.getByRole('button', { name: 'Save' }).click();
// verify success message
await expect(page.locator('text=Preferences saved successfully')).toBeVisible();
// wait for 2 seconds
await page.waitForTimeout(2000);
});
test('Should use default location in Create Collection modal', async ({ pageWithUserData: page }) => {
// test Create Collection modal
await page.locator('[data-testid="create-collection"]').click();
// verify the default location is pre-filled
const collectionLocationInput = page.getByLabel('Location');
await expect(collectionLocationInput).toHaveValue('/tmp/bruno-collections');
// cancel the collection creation
await page.getByRole('button', { name: 'Cancel' }).click();
// wait for 2 seconds
await page.waitForTimeout(2000);
});
test('Should use default location in Clone Collection modal', async ({ pageWithUserData: page }) => {
// open the clone collection modal
await page.locator('[data-testid="collection-actions"]').click();
await page.getByTestId('clone-collection').click();
// verify the default location is pre-filled
const cloneLocationInput = page.getByLabel('Location');
await expect(cloneLocationInput).toHaveValue('/tmp/bruno-collections');
// wait for 2 seconds
await page.waitForTimeout(2000);
});
});

View File

@@ -0,0 +1,10 @@
{
"collections": [
{
"path": "{{projectRoot}}/tests/preferences/default-collection-location/collection",
"securityConfig": {
"jsSandboxMode": "developer"
}
}
]
}

View File

@@ -0,0 +1,9 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/tests/preferences/default-collection-location/collection"],
"preferences": {
"general": {
"defaultCollectionLocation": "/tmp/bruno-collections"
}
}
}

View File

@@ -0,0 +1,11 @@
const closeAllCollections = async (page) => {
const numberOfCollections = await page.locator('.collection-name').count();
for (let i = 0; i < numberOfCollections; i++) {
await page.locator('.collection-name').first().locator('.collection-actions').click();
await page.locator('.dropdown-item').getByText('Close').click();
await page.getByRole('button', { name: 'Close' }).click();
}
};
export { closeAllCollections };

View File

@@ -0,0 +1 @@
export * from './actions';