Compare commits

..

61 Commits

Author SHA1 Message Date
Bijin A B
ed51f304b8 fix: variables set via setVar should be interpolated only during runtime 2026-01-15 13:35:42 +05:30
Pragadesh-45
b1e6a707bf feat: add support for interpolation on mockDataFunctions (#6393)
feat: implement `prepareMockObj` function for enhanced mock data processing in interpolation
2026-01-14 21:58:03 +05:30
Sanjai Kumar
c51381888a fix: basic Auth inheritance in code generation (#6805)
* fix: include auth in request data for GenerateCodeItem

* fix: conditionally include auth in request data for GenerateCodeItem

* fix: simplify auth inclusion in requestData

* fix: streamline auth assignment in requestData for GenerateCodeItem

* fix: clarify comments on auth resolution
2026-01-14 21:12:04 +05:30
sreelakshmi-bruno
8b1b18cc39 fix: resolve Load Request button error when loading large collection … (#6809)
* fix: resolve Load Request button error when loading large collection requests

* scope down to .bru requests
2026-01-14 19:25:39 +05:30
Pooja
707ed63be6 fix: timestamp tooltip message (#6688) 2026-01-14 17:13:54 +05:30
Chirag Chandrashekhar
bc0bb64400 fix: prevent URL marking within variable patterns in CodeMirror (#6680) 2026-01-14 14:00:28 +05:30
shubh-bruno
4c110900c1 fix: rename requests double notifications (#6677) 2026-01-14 13:28:35 +05:30
shubh-bruno
65ed6d3cfb fix: response format auto-switch on content type change (#6773) 2026-01-14 13:28:04 +05:30
Sid
7b28b05bc1 fix: add compute key for virtual table (#6807) 2026-01-14 13:09:18 +05:30
shubh-bruno
b0f27d01b9 fix: env vars loading and switching using react-virtuoso (#6790)
* fix: environment variables fetching and switching

* chore: formatting

* fix: support active, inactive, secret vars popup

* fix: variable highlight styles

* chore: codemirror styles

* fix: show variable highlighting when editor is inactive

* fix: tab press for switching columns

* fix: environment variables loading with react-virtuoso

* fix: refactor EnvironmentVariables component for improved table rendering

* fix: update react-virtuoso to version 4.18.1

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-brunos-MacBook-Air.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-01-14 12:24:07 +05:30
Pooja
3351bf990a fix: websocket message scroll (#6503)
* fix: websocket message scroll

* fix

* fix: icon color

* fix: sse message list

* fix

* rm: sort test

* rm: WSResponseSortOrder

* fix: auto scroll
2026-01-13 22:09:08 +05:30
lohit
07fff423bb feat: add node-vault util functions (#6796)
* feat: add `node-vault` util functions

* fix: review comment fixes
2026-01-13 22:06:40 +05:30
sanish chirayath
36d10ab480 feat: implement translation utilities for converting Bruno scripts pm format (#6761)
* feat: implement translation utilities for converting Bruno scripts to Postman format

- Added `bru-to-pm-translator` for translating Bruno API calls to Postman equivalents.
- Introduced `pm-to-bru-translator` for reverse translations from Postman to Bruno.
- Created utility functions in `ast-utils` for efficient AST manipulations.
- Enhanced `bruno-to-postman.js` to utilize the new translation functions for script handling.
- Updated tests to cover various translation scenarios, ensuring accuracy and reliability.

* empry commint

* refactor: migrate utility functions to ES module syntax

- Converted utility functions in `ast-utils.js` to named exports for better modularity.
- Updated import statements in `bru-to-pm-translator.js` and `pm-to-bru-translator.js` to use ES module syntax.
- Refactored test files to align with the new import structure, enhancing consistency across the codebase.

* fix: translations

* fix: add info regarding cookie apis

* simplify translations removing legacy inverse translation

* fix: add translation for getFolderVAr

* refactor: simplify transformation functions by removing change tracker

* fix: renamed files and folders

* fix: import statements

* rm : file

* simplify getSize translation
2026-01-13 20:03:14 +05:30
Abhishek S Lal
c918c679d7 fix: handle optional clientSecret in OAuth2 authorization header (#6186)
* fix: handle optional clientSecret in OAuth2 authorization header

* style: standardize string quotes in OAuth2 token functions

* test: add comprehensive tests for OAuth2 client credentials and password grant flows
2026-01-13 19:30:11 +05:30
Abhishek S Lal
7e3386b1b8 feat: allow collection environment and environment file to be used together in run command (#6784) 2026-01-13 19:24:26 +05:30
Sanjai Kumar
f4162e1ce6 feat: show skipped requests with parsing errors in report (#6780)
* feat: add support for skipped files in run command and update HTML report template

* refactor: enhance skipped file handling in run command

* fix: improve error display in HTML report for skipped requests

* test: add unit test for HTML report generation of skipped requests with parsing errors

* test: update HTML report generation tests to check for skipped request summaries

* refactor: extract skipped result creation logic into a separate utility function

* refactor: enhance skipped result processing in run command to include additional metadata

* refactor: rename and enhance createSkippedResults function for improved skipped file processing

* refactor: remove unused stripExtension import from run command

* refactor: rename createSkippedResults to createSkippedFileResults for clarity and consistency
2026-01-13 18:51:27 +05:30
gopu-bruno
e6a48a73bf fix: move yup from peerDependencies to dependencies (#6794)
* fix: move yup from peerDependencies to dependencies

* chore: update package-lock.json
2026-01-13 18:49:11 +05:30
Sanjai Kumar
fceb99edc2 fix: autosave for environment tabs and folder-level auth (#6510)
* feat: enhance autosave middleware to support environment draft and folder auth drafts

* feat: extend autosave middleware to handle global and collection environment drafts

* feat: update authentication components to use unified updateAuth function

* refactor: rename updateAuth to updateFolderAuth for consistency in authentication components
2026-01-13 16:16:46 +05:30
Pooja
ebc105d42e add: autosave missing actions in middleware (#6781) 2026-01-13 12:38:07 +05:30
Abhishek S Lal
32d56f6942 fix: update modal size in CreateEnvironment component from small to medium (#6791) 2026-01-13 12:35:44 +05:30
Abhishek S Lal
e4a1fca3b1 feat: Improve response content type detection and SVG handling (#6741)
* feat: add SVG support for HTML preview in response format handling

* feat: enhance content type detection by adding support for AVIF and SVG formats

* fix: exclude SVG from byte format type detection in response preview

* feat: add helper function to detect SVG content in response handling

* fix: ensure SVG content type detection is case insensitive and remove EPS detection

* fix: correct byte offset for AVIF content type detection in response handling
2026-01-13 12:33:55 +05:30
Abhishek S Lal
59ff9bdafb Bugfix/workspace name case mismatch (#6560)
* fix: preserve workspace name casing in title bar (#6522)

* fix: improve workspace display name handling in title bar

---------

Co-authored-by: Uzairkazi695 <kaziuzair695@gmail.com>
2026-01-12 23:15:39 +05:30
naman-bruno
071ee9ab2e feat: workspace .env file support (#6777) 2026-01-12 13:40:38 +05:30
naman-bruno
176646f983 feat: add default .gitignore file creation in workspace and collection (#6778) 2026-01-12 13:26:45 +05:30
Anas Najam
d76a574c51 correct GitHub version badge URL in readme files (#6772) 2026-01-12 12:39:02 +05:30
Pragadesh-45
734ee16fe1 feat: apply modified dataBuffer to the response (#6023)
* feat: apply modified dataBuffer to the response

* fix: ensure dataBuffer regeneration only occurs when res.setBody() is called

* refactor: update dataBuffer handling in BrunoResponse
2026-01-12 11:39:26 +05:30
Pragadesh-45
33594bdcec feat: add zoom controls to key mappings (#6765) 2026-01-09 19:50:36 +05:30
Ryan
2acfe60a5f feat/ Theme-dependent screenshots in README (#6738)
* feat: add theme-aware landing image for dark/light mode

* feat: update README to use theme-aware landing images

* fix: correct filename spacing and theme logic for landing images
2026-01-09 18:58:37 +05:30
Sid
aecaab84dd feat: add script to list changed packages (#6678) 2026-01-09 16:30:52 +05:30
Abhishek S Lal
45264bfcc5 refactor: enhance WSRequestPane and WSResponsePane with ResponsiveTabs component (#6650)
* refactor: enhance WSRequestPane and WSResponsePane with ResponsiveTabs component

- Replaced custom tab implementation with ResponsiveTabs for better consistency and usability.
- Utilized useMemo and useCallback for performance optimizations in tab management.
- Cleaned up unused styles and improved error handling in both components.
- Updated StyledWrapper to remove legacy tab styles, streamlining the component structure.

* refactor: streamline authentication components and enhance WSRequestPane layout

- Removed unnecessary margin from StyledWrapper in ApiKeyAuth, BasicAuth, and BearerAuth components for a cleaner layout.
- Introduced a new right content area in WSRequestPane for better organization of authentication modes.
- Added a 'No Auth' view in WSAuth for improved user feedback when no authentication is selected.
- Cleaned up unused imports and optimized component structure for maintainability.
2026-01-09 14:06:15 +05:30
sanish chirayath
b01b8d7bc4 fix: grpc import paths (#6726)
* fix: grpc import paths

* refactor: extract protobuf include directory logic into a separate function

* rm: comment

* fix: improve filtering of enabled import paths in protobuf configuration

* refactor: streamline import path handling in protobuf configuration
2026-01-08 21:16:47 +05:30
Abhishek S Lal
58a38ac5a1 refactor: enhance tab management in ResponseExampleResponsePane component (#6655)
- Removed local state for activeTab and integrated Redux for tab state management.
- Added logic to retrieve and update the active tab using Redux store.
- Updated tab click handler to dispatch actions for tab changes.
2026-01-08 20:43:41 +05:30
Abhishek S Lal
7328988e59 refactor: simplify HtmlPreview component by extracting render logic into a separate function (#6740)
* refactor: simplify HtmlPreview component by extracting render logic into a separate function

* refactor: wrap renderHtmlPreview in a fragment for improved JSX structure

* fix: update preview visibility check in response format tests
2026-01-08 20:04:38 +05:30
Sanjai Kumar
39a6fc837d fix: Handle deleted environment variables in UI (#6703)
* fix: enhance environment variable management in collections slice

* test: refactor deleteEnvVar test
2026-01-08 20:00:43 +05:30
Sanjai Kumar
5b1b1b5541 fix: ephemeral environment variables being saved to filesystem (#6723)
* refactor: enhance environment variable persistence logic

* refactor: simplify environment variable persistence checks
2026-01-08 19:59:01 +05:30
Abhishek S Lal
578fa72dc8 refactor: enhance GrpcRequestPane and GrpcResponsePane with ResponsiveTabs component (#6649)
* refactor: enhance GrpcRequestPane and GrpcResponsePane with ResponsiveTabs component

- Replaced custom tab implementation with ResponsiveTabs for better structure and usability.
- Utilized useMemo and useCallback for performance optimizations in GrpcRequestPane.
- Removed unused imports and simplified tab management logic.
- Updated StyledWrapper to remove legacy tab styles, improving maintainability.

* fix: handle optional chaining for auth mode in GrpcRequestPane

* feat: enhance GrpcRequestPane and GrpcResponsePane with tab initialization and response count indicators

* refactor: simplify GrpcResponsePane tab management and enhance ResponsiveTabs key handling

- Removed unnecessary useMemo for tab initialization in GrpcResponsePane.
- Updated tab comparison logic in ResponsiveTabs to use key arrays for improved performance.
- Adjusted test locator for response tab count to use role-based selection for better accessibility.

* feat: add support for 'none' auth mode in GrpcAuth and integrate GrpcAuthMode in GrpcRequestPane

- Updated StyledWrapper in ApiKeyAuth, BasicAuth, BearerAuth, OAuth2, WsseAuth, and GrpcAuth components to remove unnecessary margin-top, ensuring a uniform appearance across authentication interfaces.
- Adjusted margin in GrantTypeSelector and WSAuth components for better layout consistency.

* refactor: update import statement and enhance error handling in GrpcRequestPane

- Changed the import of 'find' from lodash to a direct import for better clarity.
- Improved error handling by returning null during initialization when requestPaneTab is not set, ensuring smoother user experience.

* refactor: integrate StyledWrapper in SearchInput for improved styling

* refactor: update StyledWrapper color and adjust margin in GrpcTimelineItem for improved layout consistency
2026-01-08 15:25:39 +05:30
Sanjai Kumar
4708e8e589 fix: enhance collection item drop logic to prevent invalid moves (#6727) 2026-01-08 14:02:16 +05:30
Abhishek S Lal
2a9386ef6b fix: update ResponseExampleUrlBar styles for better overflow handling (#6535) 2026-01-08 02:34:23 +05:30
naman-bruno
9483dbf4af fix: yml format registration on collection import (#6735) 2026-01-08 00:49:48 +05:30
Abhishek S Lal
0b436e2c9f refactor: remove HTML validation functions and simplify HtmlPreview component logic (#6730)
* refactor: remove HTML validation functions and simplify HtmlPreview component logic

* chore: fix playwright - removed body value check since response is rendered in webview

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2026-01-07 22:46:31 +05:30
Anoop M D
9005e17eb5 Merge pull request #6707 from naman-bruno/improve/default-migration
improve: migration & default workspace handling
2026-01-07 19:14:01 +05:30
gopu-bruno
efe94d9c90 fix: Large Response Warning download button functionality (#6695)
* fix: Large Response Warning download button functionality
2026-01-07 15:23:10 +05:30
ganesh
848825f16a Change home image in readme with v3 UI (#6699)
* home image

* change usebruno to Bruno

* snapshot with default theme
2026-01-07 15:22:21 +05:30
naman-bruno
4d60425a05 fix: workspace already opened (#6721) 2026-01-07 14:35:03 +05:30
Abhishek S Lal
c03fe301f8 fix: avoid error toast while pasting non-cURL value in GQL url field (#6718) 2026-01-07 13:27:38 +05:30
Bijin A B
c8371410c2 chore: minor url bar alignment fixes and refactor (#6714) 2026-01-07 08:20:02 +05:30
Chirag Chandrashekhar
dcbf38bf61 fix: Query URL overflow pushes the action buttons outside view in gRPC and HTTP (#6706)
- Updated the QueryUrl component to handle text overflow. Now, overflow triggers scroll and does not move the action buttons out of view.
- Updated the GrpcQueryUrl component to handle text overflow. Now, overflow triggers scroll and does not move the action buttons out of view.
2026-01-07 07:40:58 +05:30
naman-bruno
a57ecde1d0 improve: migration & default workspace handling 2026-01-06 21:57:25 +05:30
Abhishek S Lal
791843174e refactor: improve tab state management in ResponsiveTabs component (#6687) 2026-01-06 17:13:56 +05:30
Pragadesh-45
1174f22d88 fix: improve Content-Type handling when request body is none (#6486) (#6540) 2026-01-06 17:02:53 +05:30
naman-bruno
8300abe086 fix: auth in cli (#6675)
* fix: auth in cli

* fixes
2026-01-05 22:06:26 +05:30
Abhishek S Lal
a3809ce4b9 style: remove unnecessary padding from pre elements in StyledWrapper component (#6674) 2026-01-05 20:39:58 +05:30
gopu-bruno
adb46110dd Fix/ws environment input alignment (#6672)
* style: enhance EnvironmentList component with improved flex properties

* refactor: remove report issue link for YAML format in CreateCollection component
2026-01-05 20:28:56 +05:30
naman-bruno
7cc4c0993e fix: atomic write issue (#6664) 2026-01-05 17:29:03 +05:30
Abhishek S Lal
1030d02ac7 fix: update hover background color in dark theme (#6666) 2026-01-05 17:28:49 +05:30
Anoop M D
d616be7271 Merge pull request #6661 from naman-bruno/cli/opencollection
add: oc support for cli
2026-01-05 16:09:51 +05:30
naman-bruno
afd49d146f add: oc support for cli 2026-01-05 15:57:49 +05:30
Abhishek S Lal
97e43c4489 feat: add native select styling to global styles (#6660) 2026-01-05 15:52:34 +05:30
gopu-bruno
f9af22d586 fix: apply infoTip styling to CodeMirror tooltip (#6658)
* style: apply infoTip styling to CodeMirror tooltip

* fix: add CodeMirror lint tooltip warning and  error text colors

* fix: update font size of CodeMirror lint tooltip
2026-01-05 14:16:52 +05:30
sreelakshmi-bruno
8590bacd79 add license and readme to bruno query package (#6654) 2026-01-05 13:22:54 +05:30
Bijin A B
a7d1a349e3 fix: lighten dark pastel theme modal background color (#6653) 2026-01-04 21:46:04 +05:30
185 changed files with 6747 additions and 1804 deletions

View File

@@ -58,6 +58,8 @@ jobs:
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron
- name: Test Package bruno-requests
run: npm run test --workspace=packages/bruno-requests
cli-test:
name: CLI Tests

1
.gitignore vendored
View File

@@ -48,6 +48,7 @@ yarn-error.log*
bruno.iml
.idea
.vscode
.cursor
# Playwright
/blob-report/

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 KiB

After

Width:  |  Height:  |  Size: 584 KiB

View File

@@ -3,7 +3,7 @@
### برونو - بيئة تطوير مفتوحة المصدر لاستكشاف واختبار واجهات برمجة التطبيقات (APIs).
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### ব্রুনো - API অন্বেষণ এবং পরীক্ষা করার জন্য ওপেনসোর্স IDE।
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - 开源 IDE用于探索和测试 API。
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Opensource IDE zum Erkunden und Testen von APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE de código abierto para explorar y probar APIs.
[![Versión en Github](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![Versión en Github](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Actividad de Commits](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### برونو یا Bruno - محیط توسعه متن باز برای تست و توسعه API ها
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE Opensource pour explorer et tester des APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Opensource IDE per esplorare e testare gli APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - API の検証・動作テストのためのオープンソース IDE.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### ბრუნო - ღია წყაროების IDE API-ების შესწავლისა და ტესტირებისათვის.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - API 탐색 및 테스트를 위한 오픈소스 IDE.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Open source IDE voor het verkennen en testen van API's.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Otwartoźródłowe IDE do eksploracji i testów APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE de código aberto para explorar e testar APIs.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - Mediu integrat de dezvoltare cu sursă deschisă pentru explorarea și testarea API-urilor.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE с открытым исходным кодом для изучения и тестирования API.
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - API'leri keşfetmek ve test etmek için açık kaynaklı IDE.
[![GitHub sürümü](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub sürümü](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - IDE із відкритим кодом для тестування та дослідження API
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

View File

@@ -3,7 +3,7 @@
### Bruno - 探索和測試 API 的開源 IDE 工具
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%bruno)
[![GitHub version](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![Commit Activity](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)

449
package-lock.json generated
View File

@@ -6458,56 +6458,6 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@postman/form-data": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@postman/form-data/-/form-data-3.1.1.tgz",
"integrity": "sha512-vjh8Q2a8S6UCm/KKs31XFJqEEgmbjBmpPNVV2eVav6905wyFAwaUOBGA1NPBI4ERH9MMZc6w0umFgM6WbEPMdg==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@postman/tough-cookie": {
"version": "4.1.3-postman.1",
"resolved": "https://registry.npmjs.org/@postman/tough-cookie/-/tough-cookie-4.1.3-postman.1.tgz",
"integrity": "sha512-txpgUqZOnWYnUHZpHjkfb0IwVH4qJmyq77pPnJLlfhMtdCLMFTEeQHlzQiK906aaNCe4NEB5fGJHo9uzGbFMeA==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@postman/tough-cookie/node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/@postman/tunnel-agent": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@postman/tunnel-agent/-/tunnel-agent-0.6.4.tgz",
"integrity": "sha512-CJJlq8V7rNKhAw4sBfjixKpJW00SHqebqNUQKxMoepgeWZIbdPcD+rguRcivGhS4N12PymDcKgUgSD4rVC+RjQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/@prantlf/jsonlint": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/@prantlf/jsonlint/-/jsonlint-16.0.0.tgz",
@@ -11429,15 +11379,6 @@
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
"license": "MIT"
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/asn1.js": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz",
@@ -11476,6 +11417,7 @@
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.8"
}
@@ -11626,15 +11568,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"license": "Apache-2.0",
"engines": {
"node": "*"
}
},
"node_modules/aws4": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
@@ -12028,15 +11961,6 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@@ -12229,15 +12153,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/brotli": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.1.2"
}
},
"node_modules/browserify-aes": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
@@ -12750,12 +12665,6 @@
"node": ">=4"
}
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/chai": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
@@ -14375,18 +14284,6 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/data-urls": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz",
@@ -15110,22 +15007,6 @@
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"license": "MIT",
"dependencies": {
"jsbn": "~0.1.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ecc-jsbn/node_modules/jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -16424,12 +16305,6 @@
"node": ">= 0.6"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extract-files": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/extract-files/-/extract-files-9.0.0.tgz",
@@ -16555,6 +16430,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/fast-levenshtein": {
@@ -17037,15 +16913,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"license": "Apache-2.0",
"engines": {
"node": "*"
}
},
"node_modules/fork-ts-checker-webpack-plugin": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz",
@@ -17524,15 +17391,6 @@
"node": ">=6.0"
}
},
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0"
}
},
"node_modules/github-markdown-css": {
"version": "5.8.1",
"resolved": "https://registry.npmjs.org/github-markdown-css/-/github-markdown-css-5.8.1.tgz",
@@ -17815,57 +17673,12 @@
"@grpc/grpc-js": "^1.12.6"
}
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
"integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==",
"license": "ISC",
"engines": {
"node": ">=4"
}
},
"node_modules/har-validator": {
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz",
"integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
"deprecated": "this library is no longer supported",
"license": "MIT",
"dependencies": {
"ajv": "^6.12.3",
"har-schema": "^2.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/har-validator-compiled": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/har-validator-compiled/-/har-validator-compiled-1.0.0.tgz",
"integrity": "sha512-dher7nFSx+Ef6OoqVveLClh8itAR3vd8Qx70Lh/hEgP1iGeARAolbci7Y8JBrHIYgFCT6xRdvvL16AR9Zh07Dw==",
"license": "MIT"
},
"node_modules/har-validator/node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/har-validator/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -18338,20 +18151,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/http-signature": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz",
"integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"jsprim": "^2.0.2",
"sshpk": "^1.14.1"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/http2-wrapper": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz",
@@ -19168,12 +18967,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT"
},
"node_modules/is-valid-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz",
@@ -19236,12 +19029,6 @@
"node": ">=0.10.0"
}
},
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
"license": "MIT"
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -20822,12 +20609,6 @@
"node": "*"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -20851,7 +20632,9 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
"dev": true,
"license": "ISC",
"optional": true
},
"node_modules/json5": {
"version": "2.2.3",
@@ -20892,44 +20675,6 @@
"node": "*"
}
},
"node_modules/jsprim": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz",
"integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==",
"engines": [
"node >=0.6.0"
],
"license": "MIT",
"dependencies": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
"json-schema": "0.4.0",
"verror": "1.10.0"
}
},
"node_modules/jsprim/node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
"engines": [
"node >=0.6.0"
],
"license": "MIT"
},
"node_modules/jsprim/node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"engines": [
"node >=0.6.0"
],
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
@@ -22030,15 +21775,6 @@
"node": ">= 6.0.0"
}
},
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -22259,44 +21995,6 @@
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
"license": "MIT"
},
"node_modules/node-vault": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/node-vault/-/node-vault-0.10.2.tgz",
"integrity": "sha512-//uc9/YImE7Dx0QHdwMiAzLaOumiKUnOUP8DymgtkZ8nsq6/V2LKvEu6kw91Lcruw8lWUfj4DO7CIXNPRWBuuA==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"mustache": "^4.2.0",
"postman-request": "^2.88.1-postman.33",
"tv4": "^1.3.0"
},
"engines": {
"node": ">= 16.0.0"
}
},
"node_modules/node-vault/node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/node-vault/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -22367,15 +22065,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"license": "Apache-2.0",
"engines": {
"node": "*"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -23090,12 +22779,6 @@
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"license": "MIT"
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -24075,57 +23758,6 @@
"node": ">=15.0.0"
}
},
"node_modules/postman-request": {
"version": "2.88.1-postman.40",
"resolved": "https://registry.npmjs.org/postman-request/-/postman-request-2.88.1-postman.40.tgz",
"integrity": "sha512-uE4AiIqhjtHKp4pj9ei7fkdfNXEX9IqDBlK1plGAQne6y79UUlrTdtYLhwXoO0AMOvqyl9Ar+BU6Eo6P/MPgfg==",
"license": "Apache-2.0",
"dependencies": {
"@postman/form-data": "~3.1.1",
"@postman/tough-cookie": "~4.1.3-postman.1",
"@postman/tunnel-agent": "^0.6.4",
"aws-sign2": "~0.7.0",
"aws4": "^1.12.0",
"brotli": "^1.3.3",
"caseless": "~0.12.0",
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
"har-validator": "~5.1.3",
"http-signature": "~1.3.1",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "^2.1.35",
"oauth-sign": "~0.9.0",
"performance-now": "^2.1.0",
"qs": "~6.5.3",
"safe-buffer": "^5.1.2",
"stream-length": "^1.0.2",
"uuid": "^8.3.2"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/postman-request/node_modules/qs": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz",
"integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.6"
}
},
"node_modules/postman-request/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -24427,6 +24059,7 @@
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
@@ -24471,6 +24104,7 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -27386,37 +27020,6 @@
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"license": "BSD-3-Clause"
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"license": "MIT",
"dependencies": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
"bcrypt-pbkdf": "^1.0.0",
"dashdash": "^1.12.0",
"ecc-jsbn": "~0.1.1",
"getpass": "^0.1.1",
"jsbn": "~0.1.0",
"safer-buffer": "^2.0.2",
"tweetnacl": "~0.14.0"
},
"bin": {
"sshpk-conv": "bin/sshpk-conv",
"sshpk-sign": "bin/sshpk-sign",
"sshpk-verify": "bin/sshpk-verify"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/sshpk/node_modules/jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
"license": "MIT"
},
"node_modules/stable": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz",
@@ -27587,21 +27190,6 @@
"node": ">= 6"
}
},
"node_modules/stream-length": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/stream-length/-/stream-length-1.0.2.tgz",
"integrity": "sha512-aI+qKFiwoDV4rsXiS7WRoCt+v2RX1nUj17+KJC5r2gfh5xoSJIfP6Y3Do/HtvesFcTSWthIuJ3l1cvKQY/+nZg==",
"license": "WTFPL",
"dependencies": {
"bluebird": "^2.6.2"
}
},
"node_modules/stream-length/node_modules/bluebird": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-2.11.0.tgz",
"integrity": "sha512-UfFSr22dmHPQqPP9XWHRhq+gWnHCYguQGkXQlbyPtW5qTnhFWA8/iXg765tH0cAjy7l/zPJ1aBTO0g5XgA7kvQ==",
"license": "MIT"
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
@@ -29184,12 +28772,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -29454,6 +29036,7 @@
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
"devOptional": true,
"license": "BSD-2-Clause",
"dependencies": {
"punycode": "^2.1.0"
@@ -30492,6 +30075,7 @@
"react-player": "^2.16.0",
"react-redux": "^7.2.9",
"react-tooltip": "^5.5.2",
"react-virtuoso": "^4.18.1",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
@@ -31932,6 +31516,16 @@
"url": "https://opencollective.com/express"
}
},
"packages/bruno-app/node_modules/react-virtuoso": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.1.tgz",
"integrity": "sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==",
"license": "MIT",
"peerDependencies": {
"react": ">=16 || >=17 || >= 18 || >= 19",
"react-dom": ">=16 || >=17 || >= 18 || >=19"
}
},
"packages/bruno-app/node_modules/semver": {
"version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
@@ -35492,7 +35086,6 @@
"moment": "^2.29.4",
"nanoid": "3.3.8",
"node-fetch": "^2.7.0",
"node-vault": "^0.10.2",
"path": "^0.12.7",
"quickjs-emscripten": "^0.29.2",
"tv4": "^1.3.0",
@@ -35707,9 +35300,7 @@
"version": "0.7.0",
"license": "MIT",
"dependencies": {
"nanoid": "3.3.8"
},
"peerDependencies": {
"nanoid": "3.3.8",
"yup": "^0.32.11"
}
},

View File

@@ -84,6 +84,7 @@
"react-player": "^2.16.0",
"react-redux": "^7.2.9",
"react-tooltip": "^5.5.2",
"react-virtuoso": "^4.18.1",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",

View File

@@ -18,9 +18,9 @@ import ImportWorkspace from 'components/WorkspaceSidebar/ImportWorkspace';
import IconBottombarToggle from 'components/Icons/IconBottombarToggle/index';
import StyledWrapper from './StyledWrapper';
import { toTitleCase } from 'utils/common/index';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
import { isMacOS, isWindowsOS, isLinuxOS } from 'utils/common/platform';
import classNames from 'classnames';
const getOsClass = () => {
if (isMacOS()) return 'os-mac';
@@ -29,6 +29,12 @@ const getOsClass = () => {
return 'os-other';
};
// Helper to get display name for workspace
export const getWorkspaceDisplayName = (name) => {
if (!name) return 'Untitled Workspace';
return name;
};
const AppTitleBar = () => {
const dispatch = useDispatch();
const [isFullScreen, setIsFullScreen] = useState(false);
@@ -115,7 +121,7 @@ const AppTitleBar = () => {
const WorkspaceName = forwardRef((props, ref) => {
return (
<div ref={ref} className="workspace-name-container" {...props}>
<span className="workspace-name">{toTitleCase(activeWorkspace?.name) || 'Default Workspace'}</span>
<span data-testid="workspace-name" className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace?.name)}</span>
<IconChevronDown size={14} stroke={1.5} className="chevron-icon" />
</div>
);
@@ -127,7 +133,7 @@ const AppTitleBar = () => {
const handleWorkspaceSwitch = (workspaceUid) => {
dispatch(switchWorkspace(workspaceUid));
toast.success(`Switched to ${workspaces.find((w) => w.uid === workspaceUid)?.name}`);
toast.success(`Switched to ${getWorkspaceDisplayName(workspaces.find((w) => w.uid === workspaceUid)?.name)}`);
};
const handleOpenWorkspace = async () => {
@@ -178,7 +184,7 @@ const AppTitleBar = () => {
return {
id: workspace.uid,
label: toTitleCase(workspace.name),
label: getWorkspaceDisplayName(workspace.name),
onClick: () => handleWorkspaceSwitch(workspace.uid),
className: `workspace-item ${isActive ? 'active' : ''}`,
rightSection: (
@@ -190,11 +196,7 @@ const AppTitleBar = () => {
label={isPinned ? 'Unpin workspace' : 'Pin workspace'}
size="sm"
>
{isPinned ? (
<IconPinned size={14} stroke={1.5} />
) : (
<IconPin size={14} stroke={1.5} />
)}
{isPinned ? <IconPinned size={14} stroke={1.5} /> : <IconPin size={14} stroke={1.5} />}
</ActionIcon>
)}
{isActive && <IconCheck size={16} stroke={1.5} className="check-icon" />}
@@ -247,12 +249,7 @@ const AppTitleBar = () => {
<div className="titlebar-content">
{/* Left section: Home + Workspace */}
<div className="titlebar-left">
<ActionIcon
onClick={handleHomeClick}
label="Home"
size="lg"
className="home-button"
>
<ActionIcon onClick={handleHomeClick} label="Home" size="lg" className="home-button">
<IconHome size={16} stroke={1.5} />
</ActionIcon>

View File

@@ -259,10 +259,6 @@ const StyledWrapper = styled.div`
height: 400px;
display: flex;
flex-direction: column;
pre {
padding: 8px !important;
}
.w-full.h-full.relative.flex {
height: 100% !important;
@@ -321,7 +317,7 @@ const StyledWrapper = styled.div`
height: 100% !important;
max-height: 400px !important;
padding: 0.5rem !important;
.network-logs-pre {
color: ${(props) => props.theme.console.messageColor} !important;
font-size: ${(props) => props.theme.font.size.xs} !important;

View File

@@ -59,7 +59,7 @@ const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
return (
<Portal>
<Modal
size="sm"
size="md"
title="Create Environment"
confirmText="Create"
handleConfirm={onSubmit}

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useRef, useMemo, useEffect } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { get } from 'lodash';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
@@ -18,14 +19,28 @@ import { getGlobalEnvironmentVariables, flattenItems, isItemARequest } from 'uti
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { sensitiveFields } from './constants';
const TableRow = React.memo(({ children, item }) => <tr key={item.uid} data-testid={`env-var-row-${item.name}`}>{children}</tr>, (prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
});
const MIN_H = 35 * 2; // 2 rows worth of height
const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const [tableHeight, setTableHeight] = React.useState(MIN_H);
const environmentsDraft = collection?.environmentsDraft;
const hasDraftForThisEnv = environmentsDraft?.environmentUid === environment.uid;
const handleTotalHeightChanged = React.useCallback((h) => {
setTableHeight(h);
}, []);
// Track environment changes for draft restoration
const prevEnvUidRef = React.useRef(null);
const mountedRef = React.useRef(false);
@@ -384,111 +399,114 @@ const EnvironmentVariables = ({ environment, setIsModified, collection }) => {
return (
<StyledWrapper>
<div className="table-container">
<table>
<thead>
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={formik.values}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(index, variable) => variable.uid}
itemContent={(index, variable) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<tr key={variable.uid} data-testid={`env-var-row-${variable.name}`}>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{!variable.secret && hasSensitiveUsage(variable.name) && (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<div className="button-container">
<div className="flex items-center">

View File

@@ -191,9 +191,12 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -210,12 +213,14 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
}
&.creating {
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -232,6 +237,7 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
}

View File

@@ -3,7 +3,7 @@ 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 { updateFolderAuth as _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';
@@ -20,7 +20,7 @@ import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
import Button from 'ui/Button';
const GrantTypeComponentMap = ({ collection, folder }) => {
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
const dispatch = useDispatch();
const save = () => {
@@ -90,6 +90,13 @@ const Auth = ({ collection, folder }) => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const updateFolderAuth = ({ itemUid, ...rest }) => {
return _updateFolderAuth({
...rest,
folderUid: folder.uid
});
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
@@ -178,7 +185,7 @@ const Auth = ({ collection, folder }) => {
collection={collection}
item={folder}
/>
<GrantTypeComponentMap collection={collection} folder={folder} />
<GrantTypeComponentMap collection={collection} folder={folder} updateFolderAuth={updateFolderAuth} />
</>
);
}

View File

@@ -61,7 +61,7 @@ const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
}, [apikeyAuth]);
return (
<StyledWrapper className="mt-2 w-full">
<StyledWrapper className="w-full">
<label className="block mb-1">Key</label>
<div className="single-line-editor-wrapper mb-3">
<SingleLineEditor

View File

@@ -52,7 +52,7 @@ const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
};
return (
<StyledWrapper className="mt-2 w-full">
<StyledWrapper className="w-full">
<label className="block mb-1">Username</label>
<div className="single-line-editor-wrapper mb-3">
<SingleLineEditor

View File

@@ -38,7 +38,7 @@ const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
};
return (
<StyledWrapper className="mt-2 w-full">
<StyledWrapper className="w-full">
<label className="block mb-1">Token</label>
<div className="single-line-editor-wrapper flex items-center">
<SingleLineEditor

View File

@@ -73,7 +73,7 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
return (
<StyledWrapper>
<div className="flex items-center gap-2.5 my-4">
<div className="flex items-center gap-2.5 mb-4">
<div className="flex items-center px-2.5 py-1.5 oauth2-icon-container rounded-md">
<IconKey size={14} className="oauth2-icon" />
</div>

View File

@@ -47,7 +47,7 @@ const OAuth2 = ({ item, collection }) => {
let request = item.draft ? get(item, 'draft.request', {}) : get(item, 'request', {});
return (
<StyledWrapper className="mt-2 w-full">
<StyledWrapper className="w-full">
<GrantTypeSelector item={item} request={request} updateAuth={updateAuth} collection={collection} />
<GrantTypeComponentMap item={item} collection={collection} />
</StyledWrapper>

View File

@@ -52,7 +52,7 @@ const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
};
return (
<StyledWrapper className="mt-2 w-full">
<StyledWrapper className="w-full">
<label className="block mb-1">Username</label>
<div className="single-line-editor-wrapper mb-3">
<SingleLineEditor

View File

@@ -6,7 +6,6 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
height: 100%;
margin-right: 0.5rem;
}
.method-dropdown-trigger {

View File

@@ -60,7 +60,7 @@ const StyledWrapper = styled.div`
.proto-file-dropdown-reflection-message {
padding: 0.5rem 0.75rem;
color: ${(props) => props.theme.overlay.overlay1};
color: ${(props) => props.theme.colors.text.muted};
margin-bottom: 0.5rem;
}
`;

View File

@@ -300,7 +300,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
<span className="text-xs font-medium" style={{ color: theme.request.grpc }}>gRPC</span>
</div>
</div>
<div className="flex items-center w-full input-container h-full relative">
<div className="flex items-center w-full input-container h-full relative overflow-auto">
<SingleLineEditor
ref={editorRef}
value={url}
@@ -313,117 +313,118 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
item={item}
/>
</div>
<div className="flex items-center h-full mx-2 gap-3" id="send-request">
<MethodDropdown
grpcMethods={grpcMethods}
selectedGrpcMethod={selectedGrpcMethod}
onMethodSelect={handleGrpcMethodSelect}
onMethodDropdownCreate={onMethodDropdownCreate}
/>
<div className="flex items-center h-full mr-2 gap-3" id="send-request">
<ProtoFileDropdown
collection={collection}
item={item}
isReflectionMode={isReflectionMode}
protoFilePath={protoFilePath}
showProtoDropdown={showProtoDropdown}
setShowProtoDropdown={setShowProtoDropdown}
onProtoDropdownCreate={onProtoDropdownCreate}
onReflectionModeToggle={handleReflectionModeToggle}
onProtoFileLoad={handleProtoFileLoad}
<ProtoFileDropdown
collection={collection}
item={item}
isReflectionMode={isReflectionMode}
protoFilePath={protoFilePath}
showProtoDropdown={showProtoDropdown}
setShowProtoDropdown={setShowProtoDropdown}
onProtoDropdownCreate={onProtoDropdownCreate}
onReflectionModeToggle={handleReflectionModeToggle}
onProtoFileLoad={handleProtoFileLoad}
/>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (isReflectionMode) {
handleReflection(url, true);
} else if (protoFilePath) {
handleProtoFileLoad(protoFilePath, true);
} else {
toast.error('No proto file selected');
}
}}
>
<IconRefresh
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${(isReflectionMode ? reflectionManagement.isLoadingMethods : protoFileManagement.isLoadingMethods) ? 'animate-spin' : 'cursor-pointer'}`}
data-testid="refresh-methods-icon"
/>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (isReflectionMode) {
handleReflection(url, true);
} else if (protoFilePath) {
handleProtoFileLoad(protoFilePath, true);
} else {
toast.error('No proto file selected');
}
}}
>
<IconRefresh
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${(isReflectionMode ? reflectionManagement.isLoadingMethods : protoFileManagement.isLoadingMethods) ? 'animate-spin' : 'cursor-pointer'}`}
data-testid="refresh-methods-icon"
/>
<span className="infotip-text text-xs">
{isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'}
</span>
</div>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
handleGrpcurl(url);
}}
>
<IconCode
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
/>
<span className="infotip-text text-xs">Generate grpcurl command</span>
</div>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!item.draft) return;
onSave();
}}
>
<IconDeviceFloppy
color={item.draft ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isConnectionActive && isStreamingMethod && (
<div className="connection-controls relative flex items-center h-full gap-3">
<div className="infotip" onClick={handleCancelConnection} data-testid="grpc-cancel-connection-button">
<IconX color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotip-text text-xs">Cancel</span>
</div>
{isClientStreamingMethod && (
<div onClick={handleEndConnection} data-testid="grpc-end-connection-button">
<IconCheck
color={theme.colors.text.green}
strokeWidth={2}
size={20}
className="cursor-pointer"
/>
</div>
)}
</div>
)}
{(!isConnectionActive || !isStreamingMethod) && (
<div
className="cursor-pointer"
data-testid="grpc-send-request-button"
onClick={(e) => {
e.stopPropagation();
handleRun(e);
}}
>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={20} />
</div>
)}
<span className="infotip-text text-xs">
{isReflectionMode ? 'Refresh server reflection' : 'Refresh proto file methods'}
</span>
</div>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
handleGrpcurl(url);
}}
>
<IconCode
color={theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
/>
<span className="infotip-text text-xs">Generate grpcurl command</span>
</div>
<div
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!item.draft) return;
onSave();
}}
>
<IconDeviceFloppy
color={item.draft ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${item.draft ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotip-text text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isConnectionActive && isStreamingMethod && (
<div className="connection-controls relative flex items-center h-full gap-3">
<div className="infotip" onClick={handleCancelConnection} data-testid="grpc-cancel-connection-button">
<IconX color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotip-text text-xs">Cancel</span>
</div>
{isClientStreamingMethod && (
<div onClick={handleEndConnection} data-testid="grpc-end-connection-button">
<IconCheck
color={theme.colors.text.green}
strokeWidth={2}
size={20}
className="cursor-pointer"
/>
</div>
)}
</div>
)}
{(!isConnectionActive || !isStreamingMethod) && (
<div
className="cursor-pointer"
data-testid="grpc-send-request-button"
onClick={(e) => {
e.stopPropagation();
handleRun(e);
}}
>
<IconArrowRight color={theme.requestTabPanel.url.icon} strokeWidth={1.5} size={20} />
</div>
)}
</div>
{isConnectionActive && isStreamingMethod && (
<div className="connection-status-strip"></div>

View File

@@ -76,6 +76,9 @@ const GrpcAuth = ({ item, collection }) => {
const getAuthView = () => {
switch (authMode) {
case 'none': {
return <div>No Auth</div>;
}
case 'basic': {
return <BasicAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
@@ -98,7 +101,7 @@ const GrpcAuth = ({ item, collection }) => {
if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div className="flex flex-row w-full gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
@@ -107,7 +110,7 @@ const GrpcAuth = ({ item, collection }) => {
} else {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div className="flex flex-row w-full gap-2">
<div>Inherited auth not supported by gRPC. Using no auth instead.</div>
</div>
</>
@@ -122,9 +125,6 @@ const GrpcAuth = ({ item, collection }) => {
return (
<StyledWrapper className="w-full overflow-y-scroll">
<div className="flex flex-grow justify-start items-center">
<GrpcAuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);

View File

@@ -1,35 +1,6 @@
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: ${(props) => props.theme.tabs.marginRight};
color: ${(props) => props.theme.colors.text.subtext0};
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
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;

View File

@@ -1,34 +1,37 @@
import React from 'react';
import classnames from 'classnames';
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
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 GrpcAuthMode from './GrpcAuth/GrpcAuthMode/index';
import StatusDot from 'components/StatusDot/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import find from 'lodash/find';
import Documentation from 'components/Documentation/index';
import { useEffect } from 'react';
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import StyledWrapper from './StyledWrapper';
const GrpcRequestPane = ({ item, collection, handleRun }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const rightContentRef = useRef(null);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const selectTab = (tab) => {
const selectTab = useCallback((tab) => {
dispatch(
updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
})
);
};
}, [dispatch, item.uid]);
const getTabPanel = (tab) => {
switch (tab) {
const tabPanel = useMemo(() => {
switch (requestPaneTab) {
case 'body': {
return <GrpcBody item={item} collection={collection} hideModeSelector={true} hidePrettifyButton={true} handleRun={handleRun} />;
}
@@ -45,22 +48,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
return <div className="mt-4">404 | Not found</div>;
}
}
};
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
}, [requestPaneTab, item, collection, handleRun]);
const body = getPropertyFromDraftOrRequest(item, 'request.body');
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
@@ -74,44 +62,80 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
const request = item.draft ? item.draft.request : item.request;
const isClientStreaming = request.methodType === 'client-streaming' || request.methodType === 'bidi-streaming';
const allTabs = useMemo(() => {
const getMessageIndicator = () => {
if (grpcMessagesCount > 0) {
return isClientStreaming ? (
<sup className="ml-[.125rem] font-medium">{grpcMessagesCount}</sup>
) : (
<StatusDot />
);
}
return null;
};
return [
{
key: 'body',
label: 'Message',
indicator: getMessageIndicator()
},
{
key: 'headers',
label: 'Metadata',
indicator: activeHeadersLength > 0 ? <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup> : null
},
{
key: 'auth',
label: 'Auth',
indicator: auth?.mode && auth.mode !== 'none' ? <StatusDot type="default" /> : null
},
{
key: 'docs',
label: 'Docs',
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
}
];
}, [grpcMessagesCount, isClientStreaming, activeHeadersLength, auth?.mode, docs]);
// Initialize tab to 'body' if no tab is currently set
useEffect(() => {
// Only set the tab to 'body' if no tab is currently set
if (!focusedTab?.requestPaneTab) {
if (activeTabUid && focusedTab?.uid && !requestPaneTab) {
selectTab('body');
}
}, []);
}, [activeTabUid, focusedTab?.uid, requestPaneTab, selectTab]);
// Return error for truly missing active/focused tabs
if (!activeTabUid || !focusedTab?.uid) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
// Return null during initialization while requestPaneTab is being set by useEffect
if (!requestPaneTab) {
return null;
}
const rightContent = requestPaneTab === 'auth' ? (
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
<GrpcAuthMode item={item} collection={collection} />
</div>
) : null;
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Message
{grpcMessagesCount > 0 && (
isClientStreaming ? (
<sup className="ml-[.125rem] font-medium">{grpcMessagesCount}</sup>
) : (
<StatusDot />
)
)}
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Metadata
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <StatusDot type="default" />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
{docs && docs.length > 0 && <StatusDot type="default" />}
</div>
</div>
<ResponsiveTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={rightContent ? rightContentRef : null}
/>
<section
className={classnames('flex w-full flex-1 h-full mt-4')}
className="flex w-full flex-1 h-full mt-4"
>
<HeightBoundContainer>
{getTabPanel(focusedTab.requestPaneTab)}
{tabPanel}
</HeightBoundContainer>
</section>
</StyledWrapper>

View File

@@ -95,7 +95,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const curlCommandRegex = /^\s*curl\s/i;
if (!curlCommandRegex.test(pastedData)) {
toast.error('Invalid cURL command');
// Not a curl command, allow normal paste behavior
return;
}
event.preventDefault();
@@ -375,7 +375,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
</div>
<div
id="request-url"
className="h-full w-full flex flex-row input-container"
className="h-full w-full flex flex-row input-container overflow-auto"
>
<SingleLineEditor
ref={editorRef}
@@ -391,53 +391,54 @@ const QueryUrl = ({ item, collection, handleRun }) => {
item={item}
showNewlineArrow={true}
/>
<div className="flex items-center h-full mr-2 cursor-pointer" id="send-request" onClick={handleRun}>
<div
title="Generate Code"
className="infotip mr-3"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
title="Save Request"
className="infotip mr-3"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
onSave();
}}
>
<IconDeviceFloppy
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={20}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
) : (
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={20}
data-testid="send-arrow-icon"
/>
)}
</div>
<div className="flex items-center h-full mx-2 gap-3 cursor-pointer" id="send-request" onClick={handleRun}>
<div
title="Generate Code"
className="infotip"
onClick={(e) => {
handleGenerateCode(e);
}}
>
<IconCode color={theme.requestTabs.icon.color} strokeWidth={1.5} size={20} className="cursor-pointer" />
<span className="infotiptext text-xs">Generate Code</span>
</div>
<div
title="Save Request"
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
onSave();
}}
>
<IconDeviceFloppy
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={20}
className={`${hasChanges ? 'cursor-pointer' : 'cursor-default'}`}
/>
<span className="infotiptext text-xs">
Save <span className="shortcut">({saveShortcut})</span>
</span>
</div>
{isLoading || item.response?.stream?.running ? (
<IconSquareRoundedX
color={theme.requestTabPanel.url.iconDanger}
strokeWidth={1.5}
size={20}
data-testid="cancel-request-icon"
onClick={handleCancelRequest}
/>
) : (
<IconArrowRight
color={theme.requestTabPanel.url.icon}
strokeWidth={1.5}
size={20}
data-testid="send-arrow-icon"
/>
)}
</div>
{generateCodeItemModalOpen && (
<GenerateCodeItem

View File

@@ -1,34 +1,7 @@
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: ${(props) => props.theme.tabs.marginRight};
color: ${(props) => props.theme.colors.text.subtext0};
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;

View File

@@ -1,7 +1,6 @@
import React, { useEffect } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import WSAuthMode from './WSAuthMode';
import BearerAuth from '../../Auth/BearerAuth';
import BasicAuth from '../../Auth/BasicAuth';
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
@@ -68,6 +67,9 @@ const WSAuth = ({ item, collection }) => {
const getAuthView = () => {
switch (authMode) {
case 'none': {
return <div>No Auth</div>;
}
case 'basic': {
return <BasicAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
@@ -80,7 +82,7 @@ const WSAuth = ({ item, collection }) => {
case 'oauth2': {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div className="flex flex-row w-full gap-2">
<div>
OAuth 2 not <strong>yet</strong> supported by WebSockets. Using no auth instead.
</div>
@@ -95,7 +97,7 @@ const WSAuth = ({ item, collection }) => {
if (source && supportedAuthModes.includes(source.auth?.mode)) {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div className="flex flex-row w-full gap-2">
<div> Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
@@ -104,7 +106,7 @@ const WSAuth = ({ item, collection }) => {
} else {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div className="flex flex-row w-full gap-2">
<div>Inherited auth not supported by WebSockets. Using no auth instead.</div>
</div>
</>
@@ -119,9 +121,6 @@ const WSAuth = ({ item, collection }) => {
return (
<StyledWrapper className="w-full overflow-y-scroll">
<div className="flex flex-grow justify-start items-center">
<WSAuthMode item={item} collection={collection} />
</div>
{getAuthView()}
</StyledWrapper>
);

View File

@@ -1,16 +1,17 @@
import classnames from 'classnames';
import React, { useMemo, useCallback, useRef } from 'react';
import Documentation from 'components/Documentation/index';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import StatusDot from 'components/StatusDot/index';
import { find } from 'lodash';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
import WsBody from '../WsBody/index';
import StyledWrapper from './StyledWrapper';
import WSAuth from './WSAuth';
import WSAuthMode from './WSAuth/WSAuthMode';
import WSSettingsPane from '../WSSettingsPane/index';
const WSRequestPane = ({ item, collection, handleRun }) => {
@@ -18,15 +19,59 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
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 rightContentRef = useRef(null);
const getTabPanel = (tab) => {
switch (tab) {
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const selectTab = useCallback(
(tab) => {
dispatch(updateRequestPaneTab({
uid: item.uid,
requestPaneTab: tab
}));
},
[dispatch, item.uid]
);
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 allTabs = useMemo(() => {
return [
{
key: 'body',
label: 'Message',
indicator: null
},
{
key: 'headers',
label: 'Headers',
indicator: activeHeadersLength > 0 ? <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup> : null
},
{
key: 'auth',
label: 'Auth',
indicator: auth.mode !== 'none' ? <StatusDot type="default" /> : null
},
{
key: 'settings',
label: 'Settings',
indicator: null
},
{
key: 'docs',
label: 'Docs',
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
}
];
}, [activeHeadersLength, auth.mode, docs]);
const tabPanel = useMemo(() => {
switch (requestPaneTab) {
case 'body': {
return (
<WsBody
@@ -54,61 +99,30 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
return <div className="mt-4">404 | Not found</div>;
}
}
};
}, [requestPaneTab, item, collection, handleRun]);
if (!activeTabUid) {
return <div>Something went wrong</div>;
}
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
if (!focusedTab || !focusedTab.uid || !focusedTab.requestPaneTab) {
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const getTabClassname = (tabName) => {
return classnames(`tab select-none ${tabName}`, {
active: tabName === focusedTab.requestPaneTab
});
};
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;
useEffect(() => {
if (!focusedTab?.requestPaneTab) {
selectTab('body');
}
}, []);
const rightContent = requestPaneTab === 'auth' ? (
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
<WSAuthMode item={item} collection={collection} />
</div>
) : null;
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Message
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
{activeHeadersLength > 0 && <sup className="ml-[.125rem] font-medium">{activeHeadersLength}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <StatusDot type="default" />}
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
{docs && docs.length > 0 && <StatusDot type="default" />}
</div>
</div>
<section
className={classnames('flex w-full flex-1 h-full mt-4')}
>
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
<ResponsiveTabs
tabs={allTabs}
activeTab={requestPaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={rightContent ? rightContentRef : null}
/>
<section className="flex w-full flex-1 h-full mt-4">
<HeightBoundContainer>{tabPanel}</HeightBoundContainer>
</section>
</StyledWrapper>
);

View File

@@ -123,7 +123,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
return (
<StyledWrapper>
<div className="flex items-center h-full">
<div className="flex items-center input-container flex-1 w-full input-container pr-2 h-full relative">
<div className="flex items-center input-container flex-1 w-full h-full relative">
<div className="flex items-center justify-center px-[10px]">
<span className="text-xs font-medium method-ws">WS</span>
</div>
@@ -138,9 +138,9 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
collection={collection}
item={item}
/>
<div className="flex items-center h-full mr-2 cursor-pointer">
<div className="flex items-center h-full cursor-pointer gap-3 mx-3">
<div
className="infotip mr-3"
className="infotip"
onClick={(e) => {
e.stopPropagation();
if (!hasChanges) return;
@@ -159,7 +159,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
</div>
{connectionStatus === 'connected' && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="connection-controls relative flex items-center h-full">
<div className="infotip" onClick={(e) => handleDisconnect(e, true)}>
<IconPlugConnectedX
color={theme.colors.text.danger}
@@ -173,7 +173,7 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
)}
{connectionStatus !== 'connected' && (
<div className="connection-controls relative flex items-center h-full gap-3 mr-3">
<div className="connection-controls relative flex items-center h-full">
<div className="infotip" onClick={handleConnect}>
<IconPlugConnected
className={classnames('cursor-pointer', {

View File

@@ -61,7 +61,7 @@ const ResponseExampleUrlBar = ({ item, collection, editMode, onSave, exampleUid
<div
id="response-example-url"
className="response-example-url flex items-center flex-1 h-6"
className="response-example-url flex items-center flex-1 h-6 min-w-0 overflow-hidden"
>
<SingleLineEditor
value={url}

View File

@@ -1,5 +1,8 @@
import React, { useState, useMemo } from 'react';
import React, { useMemo } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import Tab from 'components/Tab';
import ResponseLayoutToggle from 'components/ResponsePane/ResponseLayoutToggle';
import StatusCode from 'components/ResponsePane/StatusCode';
@@ -10,7 +13,20 @@ import StyledWrapper from './StyledWrapper';
import HeightBoundContainer from 'ui/HeightBoundContainer';
const ResponseExampleResponsePane = ({ item, collection, editMode, exampleUid, onSave }) => {
const [activeTab, setActiveTab] = useState('response');
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get the focused tab for reading persisted tab state
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const activeTab = focusedTab?.responsePaneTab || 'response';
const selectTab = (tab) => {
dispatch(updateResponsePaneTab({
uid: exampleUid,
responsePaneTab: tab
}));
};
const exampleData = useMemo(() => {
return item.draft ? get(item, 'draft.examples', []).find((e) => e.uid === exampleUid) || {} : get(item, 'examples', []).find((e) => e.uid === exampleUid) || {};
@@ -67,7 +83,7 @@ const ResponseExampleResponsePane = ({ item, collection, editMode, exampleUid, o
name={tab.name}
label={tab.label}
isActive={activeTab === tab.name}
onClick={setActiveTab}
onClick={selectTab}
count={tab.count}
/>
))}

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import React, { useRef } 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';
@@ -15,13 +14,14 @@ import StyledWrapper from './StyledWrapper';
import ResponseTrailers from './ResponseTrailers';
import GrpcQueryResult from './GrpcQueryResult';
import ResponseLayoutToggle from '../ResponseLayoutToggle';
import Tab from 'components/Tab';
import ResponsiveTabs from 'ui/ResponsiveTabs';
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 rightContentRef = useRef(null);
const requestTimeline = [...(collection?.timeline || [])].filter((obj) => {
if (obj.itemUid === item.uid) return true;
@@ -38,6 +38,38 @@ const GrpcResponsePane = ({ item, collection }) => {
const response = item.response || {};
const metadataCount = Array.isArray(response.metadata) ? response.metadata.length : 0;
const trailersCount = Array.isArray(response.trailers) ? response.trailers.length : 0;
const responsesCount = Array.isArray(response.responses) ? response.responses.length : 0;
const allTabs = [
{
key: 'response',
label: 'Response',
indicator:
responsesCount > 0 ? (
<sup data-testid="grpc-tab-response-count" className="ml-1 font-medium">
{responsesCount}
</sup>
) : null
},
{
key: 'headers',
label: 'Metadata',
indicator: metadataCount > 0 ? <sup className="ml-1 font-medium">{metadataCount}</sup> : null
},
{
key: 'trailers',
label: 'Trailers',
indicator: trailersCount > 0 ? <sup className="ml-1 font-medium">{trailersCount}</sup> : null
},
{
key: 'timeline',
label: 'Timeline',
indicator: null
}
];
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
@@ -83,66 +115,40 @@ const GrpcResponsePane = ({ item, collection }) => {
return <div className="pb-4 px-4">An error occurred!</div>;
}
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'
}
];
const rightContent = !isLoading ? (
<div ref={rightContentRef} className="flex items-center">
{focusedTab?.responsePaneTab === 'timeline' ? (
<>
<ResponseLayoutToggle />
<ClearTimeline item={item} collection={collection} />
</>
) : item?.response ? (
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<GrpcStatusCode
status={response.statusCode}
text={response.statusText}
details={response.statusDescription}
/>
<ResponseTime duration={response.duration} />
</>
) : null}
</div>
) : null;
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist" data-testid="grpc-response-tabs">
{tabConfig.map((tab) => (
<Tab
key={tab.name}
name={tab.name}
label={tab.label}
isActive={focusedTab.responsePaneTab === tab.name}
onClick={selectTab}
count={tab.count}
/>
))}
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">
{focusedTab?.responsePaneTab === 'timeline' ? (
<>
<ResponseLayoutToggle />
<ClearTimeline item={item} collection={collection} />
</>
) : item?.response ? (
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<GrpcStatusCode
status={response.statusCode}
text={response.statusText}
details={response.statusDescription}
/>
<ResponseTime duration={response.duration} />
</>
) : null}
</div>
) : null}
<div className="px-4">
<ResponsiveTabs
tabs={allTabs}
activeTab={focusedTab.responsePaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={rightContentRef}
/>
</div>
<section
className="flex flex-col flex-grow pl-3 pr-4 h-0 mt-4"
>
<section className="flex flex-col flex-grow px-4 h-0 mt-4">
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (

View File

@@ -10,12 +10,14 @@ const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => {
const { ipcRenderer } = window;
const response = item.response || {};
const saveResponseToFile = () => {
const downloadResponseToFile = () => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item.requestSent.url)
.then(() => {
toast.success('Response saved to file');
.invoke('renderer:save-response-to-file', response, item.requestSent.url, item.pathname)
.then((result) => {
if (result && result.success) {
toast.success('Response downloaded to file');
}
resolve();
})
.catch((err) => {
@@ -72,13 +74,13 @@ const LargeResponseWarning = ({ item, responseSize, onRevealResponse }) => {
<Button
icon={<IconDownload size={18} strokeWidth={1.5} />}
iconPosition="left"
onClick={saveResponseToFile}
onClick={downloadResponseToFile}
disabled={!response.dataBuffer}
title="Save response to file"
title="Download response to file"
color="secondary"
size="sm"
>
Save
Download
</Button>
<Button
icon={<IconCopy size={18} strokeWidth={1.5} />}

View File

@@ -1,6 +1,5 @@
import React, { useRef, useState, useEffect } from 'react';
import { isValidHtml } from 'utils/common/index';
import { escapeHtml, isValidHtmlSnippet } from 'utils/response/index';
import { escapeHtml } from 'utils/response/index';
const HtmlPreview = React.memo(({ data, baseUrl }) => {
const webviewContainerRef = useRef(null);
@@ -31,7 +30,7 @@ const HtmlPreview = React.memo(({ data, baseUrl }) => {
return () => mutationObserver.disconnect();
}, []);
if (isValidHtml(data) || isValidHtmlSnippet(data)) {
const renderHtmlPreview = (data, baseUrl, isDragging, webviewContainerRef) => {
const htmlContent = data.includes('<head>')
? data.replace('<head>', `<head><base href="${escapeHtml(baseUrl)}">`)
: `<head><base href="${escapeHtml(baseUrl)}"></head>${data}`;
@@ -52,7 +51,7 @@ const HtmlPreview = React.memo(({ data, baseUrl }) => {
/>
</div>
);
}
};
// For all other data types, render safely as formatted text
let displayContent = '';
@@ -60,18 +59,12 @@ const HtmlPreview = React.memo(({ data, baseUrl }) => {
displayContent = String(data);
} else if (typeof data === 'object') {
displayContent = JSON.stringify(data, null);
} else if (typeof data === 'string') {
displayContent = data;
} else {
displayContent = String(data);
}
return (
<pre
className="bg-white font-mono text-[13px] whitespace-pre-wrap break-words overflow-auto overflow-x-hidden p-4 text-[#24292f] w-full max-w-full h-full box-border relative"
>
{displayContent}
</pre>
<>{renderHtmlPreview(displayContent, baseUrl, isDragging, webviewContainerRef)}</>
);
});

View File

@@ -49,11 +49,11 @@ export const useInitialResponseFormat = (dataBuffer, headers) => {
// Wait until both content types are available
if (detectedContentType === null || contentType === undefined) {
return { initialFormat: null, initialTab: null };
return { initialFormat: null, initialTab: null, contentType: contentType };
}
const initial = getDefaultResponseFormat(contentType);
return { initialFormat: initial.format, initialTab: initial.tab };
return { initialFormat: initial.format, initialTab: initial.tab, contentType: contentType };
}, [dataBuffer, headers]);
};
@@ -66,6 +66,7 @@ export const useResponsePreviewFormatOptions = (dataBuffer, headers) => {
const byteFormatTypes = ['image', 'video', 'audio', 'pdf', 'zip'];
const isByteFormatType = (contentType) => {
if (contentType.toLowerCase().includes('svg')) return false; // SVG is text-based
return byteFormatTypes.some((type) => contentType.includes(type));
};
@@ -203,7 +204,7 @@ const QueryResult = ({
dataBuffer={dataBuffer}
formattedData={formattedData}
item={item}
contentType={contentType}
contentType={detectedContentType ?? contentType}
previewMode={previewMode}
codeMirrorMode={codeMirrorMode}
collection={collection}

View File

@@ -24,7 +24,12 @@ const ResponseDownload = forwardRef(({ item, children }, ref) => {
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:save-response-to-file', response, item?.requestSent?.url, item.pathname)
.then(resolve)
.then((result) => {
if (result && result.success) {
toast.success('Response downloaded to file');
}
resolve();
})
.catch((err) => {
toast.error(get(err, 'error.message') || 'Something went wrong!');
reject(err);

View File

@@ -245,7 +245,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, eventData,
};
return (
<StyledWrapper className={`${eventClass} pl-1`}>
<StyledWrapper className={`${eventClass} pl-1 mb-2`}>
<div className="event-header" onClick={toggleCollapse}>
{isCollapsed ? <IconChevronRight size={16} strokeWidth={1.5} /> : <IconChevronDown size={16} strokeWidth={1.5} />}
<div className="event-icon-container">

View File

@@ -53,6 +53,17 @@ const StyledWrapper = styled.div`
align-items: center;
margin-left: 10px;
}
div.tabs .action-icon {
color: ${(props) => props.theme.dropdown.iconColor};
opacity: 0.8;
&:hover {
color: ${(props) => props.theme.text};
opacity: 1;
background-color: ${(props) => props.theme.workspace.button.bg};
}
}
`;
export default StyledWrapper;

View File

@@ -1,7 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
overflow-y: auto;
flex: 1;
min-height: 0;
height: 100%;
.empty-state {
padding: 1rem;

View File

@@ -1,13 +1,11 @@
import React from 'react';
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
import { IconExclamationCircle, IconChevronRight, IconInfoCircle, IconChevronDown, IconArrowUpRight, IconArrowDownLeft } from '@tabler/icons';
import CodeEditor from 'components/CodeEditor/index';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useSelector } from 'react-redux';
import { useRef } from 'react';
import { useEffect } from 'react';
import { Virtuoso } from 'react-virtuoso';
const getContentMeta = (content) => {
if (typeof content === 'object') {
@@ -61,8 +59,7 @@ const TypeIcon = ({ type }) => {
}[type];
};
const WSMessageItem = ({ message, inFocus }) => {
const [isOpen, setIsOpen] = useState(false);
const WSMessageItem = memo(({ message, isOpen, onToggle }) => {
const [showHex, setShowHex] = useState(false);
const preferences = useSelector((state) => state.app.preferences);
const { displayedTheme } = useTheme();
@@ -82,21 +79,23 @@ const WSMessageItem = ({ message, inFocus }) => {
const dateDiff = Date.now() - new Date(message.timestamp).getTime();
if (dateDiff < 1000 * 10) {
setIsNew(true);
setTimeout(() => {
const timer = setTimeout(() => {
notified.current = true;
setIsNew(false);
}, 2500);
return () => clearTimeout(timer);
}
}, [message]);
}, [message.timestamp]);
const canOpenMessage = !isInfo && !isError;
const handleToggle = () => {
if (!canOpenMessage) return;
onToggle?.(message.timestamp);
};
return (
<div
ref={(node) => {
if (!node) return;
if (inFocus) node.scrollIntoView();
}}
className={classnames('ws-message flex flex-col p-2', {
'ws-incoming': isIncoming,
'ws-outgoing': isOutgoing,
@@ -111,10 +110,7 @@ const WSMessageItem = ({ message, inFocus }) => {
'cursor-pointer': canOpenMessage,
'cursor-not-allowed': !canOpenMessage
})}
onClick={(e) => {
if (!canOpenMessage) return;
setIsOpen(!isOpen);
}}
onClick={handleToggle}
>
<div className="flex min-w-0 shrink">
<span className="message-type-icon">
@@ -176,23 +172,87 @@ const WSMessageItem = ({ message, inFocus }) => {
)}
</div>
);
};
});
const WSMessagesList = ({ messages = [] }) => {
const virtuosoRef = useRef(null);
const [scrollerElement, setScrollerElement] = useState(null);
const [openMessages, setOpenMessages] = useState(new Set());
const userScrolledAwayRef = useRef(false);
// Toggle message open/closed state by timestamp
const handleMessageToggle = useCallback((timestamp) => {
setOpenMessages((prev) => {
const next = new Set(prev);
if (next.has(timestamp)) {
next.delete(timestamp);
} else {
next.add(timestamp);
}
return next;
});
}, []);
useEffect(() => {
if (!scrollerElement) return;
const handleWheel = (e) => {
// deltaY < 0 means scrolling up
if (e.deltaY < 0) {
userScrolledAwayRef.current = true;
}
};
scrollerElement.addEventListener('wheel', handleWheel, { passive: true });
return () => {
scrollerElement.removeEventListener('wheel', handleWheel);
};
}, [scrollerElement]);
const handleAtBottomStateChange = useCallback((atBottom) => {
if (atBottom) {
// User scrolled back to bottom, re-enable auto-scroll
userScrolledAwayRef.current = false;
}
}, []);
const followOutput = useCallback((isAtBottom) => {
// Don't auto-scroll if user has scrolled away or has messages open
if (userScrolledAwayRef.current || openMessages.size > 0) {
return false;
}
if (isAtBottom) {
return 'smooth';
}
return false;
}, [openMessages.size]);
const renderItem = useCallback((_, msg) => {
const isOpen = openMessages.has(msg.timestamp);
return <WSMessageItem message={msg} isOpen={isOpen} onToggle={handleMessageToggle} />;
}, [openMessages, handleMessageToggle]);
const computeItemKey = useCallback((_, msg) => {
return msg.seq ?? msg.timestamp;
}, []);
const WSMessagesList = ({ order = -1, messages = [] }) => {
if (!messages.length) {
return <StyledWrapper><div className="empty-state">No messages yet.</div></StyledWrapper>;
}
// sort based on order, seq was newly added and might be missing in some cases and when missing,
// the timestamp will be used instead
const ordered = messages.toSorted((x, y) => ((x.seq ?? x.timestamp) - (y.seq ?? y.timestamp)) * (-order));
return (
<StyledWrapper className="ws-messages-list flex flex-col">
{ordered.map((msg, idx, src) => {
const inFocus = order === -1 ? src.length - 1 === idx : idx === 0;
return <WSMessageItem key={msg.seq ? msg.seq : msg.timestamp} inFocus={inFocus} id={idx} message={msg} />;
})}
<Virtuoso
ref={virtuosoRef}
scrollerRef={setScrollerElement}
data={messages}
itemContent={renderItem}
computeItemKey={computeItemKey}
followOutput={followOutput}
initialTopMostItemIndex={messages.length - 1}
atBottomStateChange={handleAtBottomStateChange}
/>
</StyledWrapper>
);
};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo, useRef } from 'react';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -11,13 +11,12 @@ import ClearTimeline from '../ClearTimeline';
import ResponseClear from '../ResponseClear';
import StyledWrapper from './StyledWrapper';
import ResponseLayoutToggle from '../ResponseLayoutToggle';
import Tab from 'components/Tab';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import WSMessagesList from './WSMessagesList';
import WSResponseSortOrder from './WSResponseSortOrder';
import WSResponseHeaders from './WSResponseHeaders';
const WSResult = ({ response }) => {
return <WSMessagesList order={response?.sortOrder} messages={response.responses || []} />;
return <WSMessagesList messages={response.responses || []} />;
};
const WSResponsePane = ({ item, collection }) => {
@@ -25,6 +24,7 @@ const WSResponsePane = ({ item, collection }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isLoading = ['queued', 'sending'].includes(item.requestState);
const rightContentRef = useRef(null);
const requestTimeline = [...(collection?.timeline || [])].filter((obj) => {
if (obj.itemUid === item.uid) return true;
@@ -39,6 +39,29 @@ const WSResponsePane = ({ item, collection }) => {
const response = item.response || {};
const messagesCount = Array.isArray(response.responses) ? response.responses.length : 0;
const headersCount = response.headers ? Object.keys(response.headers).length : 0;
const allTabs = useMemo(() => {
return [
{
key: 'response',
label: 'Messages',
indicator: messagesCount > 0 ? <sup className="ml-1 font-medium">{messagesCount}</sup> : null
},
{
key: 'headers',
label: 'Headers',
indicator: headersCount > 0 ? <sup className="ml-1 font-medium">{headersCount}</sup> : null
},
{
key: 'timeline',
label: 'Timeline',
indicator: null
}
];
}, [messagesCount, headersCount]);
const getTabPanel = (tab) => {
switch (tab) {
case 'response': {
@@ -81,62 +104,40 @@ const WSResponsePane = ({ item, collection }) => {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const tabConfig = [
{
name: 'response',
label: 'Messages',
count: Array.isArray(response.responses) ? response.responses.length : 0
},
{
name: 'headers',
label: 'Headers',
count: response.headers ? Object.keys(response.headers).length : 0
},
{
name: 'timeline',
label: 'Timeline'
}
];
const rightContent = !isLoading ? (
<div ref={rightContentRef} className="flex items-center">
{focusedTab?.responsePaneTab === 'timeline' ? (
<>
<ResponseLayoutToggle />
<ClearTimeline item={item} collection={collection} />
</>
) : item?.response ? (
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<WSStatusCode
status={response.statusCode}
text={response.statusText}
details={response.statusDescription}
/>
<ResponseTime duration={response.duration} />
</>
) : null}
</div>
) : null;
return (
<StyledWrapper className="flex flex-col h-full relative">
<div className="flex flex-wrap items-center pl-3 pr-4 tabs" role="tablist">
{tabConfig.map((tab) => (
<Tab
key={tab.name}
name={tab.name}
label={tab.label}
isActive={focusedTab.responsePaneTab === tab.name}
onClick={selectTab}
count={tab.count}
/>
))}
{!isLoading ? (
<div className="flex flex-grow justify-end items-center">
{focusedTab?.responsePaneTab === 'timeline' ? (
<>
<ResponseLayoutToggle />
<ClearTimeline item={item} collection={collection} />
</>
) : item?.response ? (
<>
<ResponseLayoutToggle />
<ResponseClear item={item} collection={collection} />
<WSResponseSortOrder item={item} collection={collection} />
<WSStatusCode
status={response.statusCode}
text={response.statusText}
details={response.statusDescription}
/>
<ResponseTime duration={response.duration} />
</>
) : null}
</div>
) : null}
<div className="px-4">
<ResponsiveTabs
tabs={allTabs}
activeTab={focusedTab.responsePaneTab}
onTabSelect={selectTab}
rightContent={rightContent}
rightContentRef={rightContentRef}
/>
</div>
<section
className="flex flex-col flex-grow pl-3 pr-4 h-0 mt-4"
>
<section className="flex flex-col flex-grow px-4 h-0 mt-4">
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (

View File

@@ -42,9 +42,12 @@ const ResponsePane = ({ item, collection }) => {
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
// Initialize format and tab only once when data loads.
const { initialFormat, initialTab } = useInitialResponseFormat(response?.dataBuffer, response?.headers);
const { initialFormat, initialTab, contentType } = useInitialResponseFormat(response?.dataBuffer, response?.headers);
const previewFormatOptions = useResponsePreviewFormatOptions(response?.dataBuffer, response?.headers);
// Track previous response headers to detect when content-type changes
const previousContentRef = useRef(contentType);
const persistedFormat = focusedTab?.responseFormat;
const persistedViewTab = focusedTab?.responseViewTab;
@@ -56,13 +59,19 @@ const ResponsePane = ({ item, collection }) => {
if (!focusedTab || initialFormat === null || initialTab === null) {
return;
}
if (persistedFormat === null) {
// Check if response headers (content-type) changed using deep comparison
const contentTypeChanged = contentType !== previousContentRef.current;
if (contentTypeChanged) {
previousContentRef.current = contentType;
}
if (contentTypeChanged || persistedFormat === null) {
dispatch(updateResponseFormat({ uid: item.uid, responseFormat: initialFormat }));
}
if (persistedViewTab === null) {
if (contentTypeChanged || persistedViewTab === null) {
dispatch(updateResponseViewTab({ uid: item.uid, responseViewTab: initialTab }));
}
}, [initialFormat, initialTab, persistedFormat, persistedViewTab, focusedTab, item.uid, dispatch]);
}, [contentType, initialFormat, initialTab, persistedFormat, persistedViewTab, focusedTab, item.uid, dispatch]);
const handleFormatChange = useCallback((newFormat) => {
dispatch(updateResponseFormat({ uid: item.uid, responseFormat: newFormat }));

View File

@@ -0,0 +1,36 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
.search-icon {
color: ${(props) => props.theme.colors.text.muted};
}
.close-icon {
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
&:hover {
color: ${(props) => props.theme.text};
}
}
input#search-input {
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
}
&::placeholder {
color: ${(props) => props.theme.input.placeholder.color};
opacity: ${(props) => props.theme.input.placeholder.opacity};
}
}
`;
export default StyledWrapper;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { IconSearch, IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const SearchInput = ({
searchText,
@@ -17,9 +18,9 @@ const SearchInput = ({
};
return (
<div className={`relative px-2 ${className}`}>
<StyledWrapper className={`px-2 ${className}`}>
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<span className="text-gray-500">
<span className="search-icon">
<IconSearch size={16} strokeWidth={1.5} />
</span>
</div>
@@ -50,7 +51,7 @@ const SearchInput = ({
</span>
</div>
)}
</div>
</StyledWrapper>
);
};

View File

@@ -110,12 +110,14 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
// Resolve auth inheritance
const resolvedRequest = resolveInheritedAuth(item, collection);
// Create the final item for code generation
// requestData.request contains either the normal request or example request data.
// We explicitly set auth from resolvedRequest to ensure inherited auth
// (from folders/collection) is resolved correctly in generated code.
const finalItem = {
...item,
request: {
...resolvedRequest,
...requestData.request,
auth: resolvedRequest.auth,
url: finalUrl
}
};

View File

@@ -252,21 +252,6 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
{formik.touched.format && formik.errors.format ? (
<div className="text-red-500">{formik.errors.format}</div>
) : null}
{formik.values.format === 'yml' && (
<div className="mt-2">
<a
href="#"
className="report-issue-link"
onClick={(e) => {
e.preventDefault();
window.open('https://github.com/usebruno/bruno/discussions/6466', '_blank', 'noopener,noreferrer');
}}
>
<IconExternalLink size={14} strokeWidth={1.5} />
<span>Report an issue</span>
</a>
</div>
)}
</div>
</div>
<div className="flex justify-end items-center mt-8 bruno-modal-footer">

View File

@@ -1,4 +1,5 @@
import React, { useCallback, useRef } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
@@ -19,6 +20,14 @@ import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import Button from 'ui/Button';
const MIN_H = 35 * 2;
const TableRow = React.memo(({ children, item }) => <tr key={item.uid} data-testid={`env-var-row-${item.name}`}>{children}</tr>, (prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
});
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
@@ -28,6 +37,12 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
const hasDraftForThisEnv = globalEnvironmentDraft?.environmentUid === environment.uid;
const [tableHeight, setTableHeight] = React.useState(MIN_H);
const handleTotalHeightChanged = React.useCallback((h) => {
setTableHeight(h);
}, []);
// Track environment changes for draft restoration
const prevEnvUidRef = React.useRef(null);
const mountedRef = React.useRef(false);
@@ -322,109 +337,108 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
return (
<StyledWrapper>
<div className="table-container">
<table>
<thead>
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
</thead>
<tbody>
{formik.values.map((variable, index) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
totalListHeightChanged={handleTotalHeightChanged}
data={formik.values}
fixedItemHeight={35}
components={{ TableRow }}
computeItemKey={(index, variable) => variable.uid}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td>Name</td>
<td>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
itemContent={(index, variable) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<tr key={variable.uid}>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle
id={`${variable.uid}-disabled-info-icon`}
className="text-muted"
size={16}
/>
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${index}.name`}
name={`${index}.name`}
value={variable.name}
placeholder={isLastEmptyRow ? 'Name' : ''}
onChange={(e) => handleNameChange(index, e)}
onBlur={() => handleNameBlur(index)}
onKeyDown={(e) => handleNameKeyDown(index, e)}
/>
<ErrorMessage name={`${index}.name`} index={index} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${index}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<div className="button-container mt-5">
<div className="flex items-center gap-2">

View File

@@ -191,9 +191,12 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -210,12 +213,14 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
}
&.creating {
.environment-name-input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
@@ -232,6 +237,7 @@ const StyledWrapper = styled.div`
display: flex;
gap: 2px;
margin-left: 4px;
flex-shrink: 0;
}
.inline-action-btn {

View File

@@ -11,6 +11,8 @@ import WorkspaceTabs from 'components/WorkspaceTabs';
import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { getWorkspaceDisplayName } from 'components/AppTitleBar';
import classNames from 'classnames';
const WorkspaceHome = () => {
const dispatch = useDispatch();
@@ -208,7 +210,7 @@ const WorkspaceHome = () => {
</div>
</div>
) : (
<span>{activeWorkspace.name}</span>
<span className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace.name)}</span>
)}
</div>

View File

@@ -324,6 +324,27 @@ const GlobalStyle = createGlobalStyle`
margin: 1em 0;
}
.CodeMirror-lint-tooltip {
padding: 4px 8px;
background-color: ${(props) => props.theme.infoTip.bg};
border: 1px solid ${(props) => props.theme.infoTip.border};
box-shadow: ${(props) => props.theme.infoTip.boxShadow};
border-radius: ${(props) => props.theme.border.radius.sm};
}
.CodeMirror-lint-message {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.text};
}
.CodeMirror-lint-message-warning {
color: ${(props) => props.theme.status.warning.text};
}
.CodeMirror-lint-message-error {
color: ${(props) => props.theme.status.danger.text};
}
/* Header */
.CodeMirror-brunoVarInfo .var-info-header {
display: flex;
@@ -540,6 +561,25 @@ const GlobalStyle = createGlobalStyle`
cursor: pointer;
color: ${(props) => props.theme.textLink} !important;
}
// Native select styling
select {
background-color: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.base};
font-weight: 400;
}
select option {
background-color: ${(props) => props.theme.dropdown.bg};
color: ${(props) => props.theme.dropdown.color};
}
select option:hover,
select option:focus {
background-color: ${(props) => props.theme.dropdown.hoverBg} !important;
color: ${(props) => props.theme.dropdown.color} !important;
}
`;
export default GlobalStyle;

View File

@@ -15,6 +15,7 @@ import {
collectionUnlinkEnvFileEvent,
collectionUnlinkFileEvent,
processEnvUpdateEvent,
workspaceEnvUpdateEvent,
requestCancelled,
runFolderEvent,
runRequestEvent,
@@ -23,6 +24,7 @@ import {
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions';
import { workspaceDotEnvUpdateEvent } from 'providers/ReduxStore/slices/workspaces';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import { isElectron } from 'utils/common/platform';
@@ -214,6 +216,11 @@ const useIpcEvents = () => {
dispatch(processEnvUpdateEvent(val));
});
const removeWorkspaceDotEnvUpdatesListener = ipcRenderer.on('main:workspace-dotenv-update', (val) => {
dispatch(workspaceDotEnvUpdateEvent(val));
dispatch(workspaceEnvUpdateEvent({ processEnvVariables: val.processEnvVariables }));
});
const removeConsoleLogListener = ipcRenderer.on('main:console-log', (val) => {
console[val.type](...val.args);
dispatch(addLog({
@@ -293,6 +300,7 @@ const useIpcEvents = () => {
removeRunFolderEventListener();
removeRunRequestEventListener();
removeProcessEnvUpdatesListener();
removeWorkspaceDotEnvUpdatesListener();
removeConsoleLogListener();
removeConfigUpdatesListener();
removeShowPreferencesListener();

View File

@@ -32,7 +32,10 @@ const KeyMapping = {
name: 'Move Tab Right'
},
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' }
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' },
zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' },
zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' },
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' }
};
/**

View File

@@ -1,4 +1,5 @@
import { saveRequest, saveCollectionSettings, saveFolderRoot } from '../../slices/collections/actions';
import { saveRequest, saveCollectionSettings, saveFolderRoot, saveEnvironment } from '../../slices/collections/actions';
import { saveGlobalEnvironment } from '../../slices/global-environments';
import { flattenItems, isItemARequest, isItemAFolder } from 'utils/collections';
const actionsToIntercept = [
@@ -46,6 +47,11 @@ const actionsToIntercept = [
'collections/updateRequestDocs',
'collections/runRequestEvent',
'collections/updateCollectionPresets',
'collections/setRequestVars',
'collections/setRequestAssertions',
'collections/updateItemSettings',
'collections/addRequestTag',
'collections/deleteRequestTag',
// Folder-level actions
'collections/addFolderHeader',
@@ -80,7 +86,11 @@ const actionsToIntercept = [
'collections/updateCollectionDocs',
'collections/updateCollectionClientCertificates',
'collections/updateCollectionProtobuf',
'collections/updateCollectionProxy'
'collections/updateCollectionProxy',
// Environment draft actions
'collections/setEnvironmentsDraft',
'globalEnvironments/setGlobalEnvironmentDraft'
];
// Simple object to track pending save timers
@@ -100,7 +110,8 @@ const scheduleAutoSave = (key, save, interval) => {
// Helper to find and schedule saves for all existing drafts
const saveExistingDrafts = (dispatch, getState, interval) => {
const collections = getState().collections.collections;
const state = getState();
const collections = state.collections.collections;
collections.forEach((collection) => {
// Check collection-level draft
@@ -109,6 +120,15 @@ const saveExistingDrafts = (dispatch, getState, interval) => {
scheduleAutoSave(key, () => dispatch(saveCollectionSettings(collection.uid, null, true)), interval);
}
// Check collection environment drafts
if (collection.environmentsDraft) {
const { environmentUid, variables } = collection.environmentsDraft;
if (environmentUid && variables) {
const key = `environment-${collection.uid}-${environmentUid}`;
scheduleAutoSave(key, () => dispatch(saveEnvironment(variables, environmentUid, collection.uid)), interval);
}
}
// Check all items (requests and folders) for drafts
const allItems = flattenItems(collection.items);
allItems.forEach((item) => {
@@ -123,6 +143,77 @@ const saveExistingDrafts = (dispatch, getState, interval) => {
}
});
});
// Check global environment drafts
const globalEnvironmentDraft = state.globalEnvironments?.globalEnvironmentDraft;
if (globalEnvironmentDraft) {
const { environmentUid, variables } = globalEnvironmentDraft;
if (environmentUid && variables) {
const key = `global-environment-${environmentUid}`;
scheduleAutoSave(key, () => dispatch(saveGlobalEnvironment({ variables, environmentUid })), interval);
}
}
};
// Helper to determine entity type and create save handler
const determineSaveHandler = (actionType, payload, dispatch, getState) => {
const { itemUid, folderUid, collectionUid, environmentUid } = payload;
// Handle environment drafts
if (actionType === 'collections/setEnvironmentsDraft') {
if (!environmentUid || !collectionUid) return null;
return {
key: `environment-${collectionUid}-${environmentUid}`,
save: () => {
const state = getState();
const collection = state.collections.collections.find((c) => c.uid === collectionUid);
const draft = collection?.environmentsDraft;
if (draft?.environmentUid === environmentUid && draft?.variables) {
dispatch(saveEnvironment(draft.variables, environmentUid, collectionUid));
}
}
};
}
if (actionType === 'globalEnvironments/setGlobalEnvironmentDraft') {
if (!environmentUid) return null;
return {
key: `global-environment-${environmentUid}`,
save: () => {
const state = getState();
const draft = state.globalEnvironments?.globalEnvironmentDraft;
if (draft?.environmentUid === environmentUid && draft?.variables) {
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid }));
}
}
};
}
// Handle folder actions
if (folderUid) {
return {
key: `folder-${folderUid}`,
save: () => dispatch(saveFolderRoot(collectionUid, folderUid, true))
};
}
// Handle request actions
if (itemUid) {
return {
key: `request-${itemUid}`,
save: () => dispatch(saveRequest(itemUid, collectionUid, true))
};
}
// Handle collection-level changes
if (collectionUid) {
return {
key: `collection-${collectionUid}`,
save: () => dispatch(saveCollectionSettings(collectionUid, null, true))
};
}
return null;
};
export const autosaveMiddleware = ({ dispatch, getState }) => (next) => (action) => {
@@ -150,28 +241,9 @@ export const autosaveMiddleware = ({ dispatch, getState }) => (next) => (action)
// Only handle actions that create dirty state
if (!actionsToIntercept.includes(action.type)) return result;
const { itemUid, folderUid, collectionUid } = action.payload;
const interval = autoSave.interval;
// Determine what to save based on what IDs are present
let key, save;
if (itemUid) {
// Request change
key = `request-${itemUid}`;
save = () => dispatch(saveRequest(itemUid, collectionUid, true));
} else if (folderUid) {
// Folder change
key = `folder-${folderUid}`;
save = () => dispatch(saveFolderRoot(collectionUid, folderUid, true));
} else if (collectionUid) {
// Collection change
key = `collection-${collectionUid}`;
save = () => dispatch(saveCollectionSettings(collectionUid, null, true));
}
if (key && save) {
scheduleAutoSave(key, save, interval);
const handler = determineSaveHandler(action.type, action.payload, dispatch, getState);
if (handler) {
scheduleAutoSave(handler.key, handler.save, autoSave.interval);
}
return result;

View File

@@ -35,6 +35,7 @@ import {
sortCollections as _sortCollections,
updateCollectionMountStatus,
moveCollection,
workspaceEnvUpdateEvent,
requestCancelled,
resetRunResults,
responseReceived,
@@ -816,7 +817,6 @@ export const renameItem
return ipcRenderer
.invoke('renderer:rename-item-filename', { oldPath: item.pathname, newPath, newName, newFilename, collectionPathname: collection.pathname })
.catch((err) => {
toast.error('Failed to rename the file');
console.error(err);
throw new Error('Failed to rename the file');
});
@@ -1129,7 +1129,7 @@ export const handleCollectionItemDrop
// Update sequences in the target directory (if dropping adjacent)
if (dropType === 'adjacent') {
const targetItemSequence = targetItemDirectoryItems.findIndex((i) => i.uid === targetItemUid)?.seq;
const targetItemSequence = targetItemDirectoryItems.find((i) => i.uid === targetItemUid)?.seq;
const draggedItemWithNewPathAndSequence = {
...draggedItem,
@@ -1176,6 +1176,14 @@ export const handleCollectionItemDrop
});
if (!newPathname) return;
if (targetItemPathname?.startsWith(draggedItemPathname)) return;
// Discard operation if dragging a root item to the collection name (same location)
const isTargetTheCollection = targetItemPathname === collection.pathname;
const isDraggedItemAtRoot = draggedItemDirectory === sourceCollection;
if (isTargetTheCollection && isDraggedItemAtRoot && !isCrossCollectionMove) {
return;
}
if (newPathname !== draggedItemPathname) {
await handleMoveToNewLocation({
targetItem,
@@ -1755,8 +1763,8 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
Modal Save writes what the user sees:
- Non-ephemeral vars are saved as-is (without metadata)
- Ephemeral vars:
- if persistedValue exists, save that (explicit persisted case)
- otherwise save the current UI value (treat as user-authored)
- if persistedValue exists, save that (restore original value)
- otherwise filter out (don't save script-created ephemeral vars)
*/
const persisted = buildPersistedEnvVariables(variables, { mode: 'save' });
environment.variables = persisted;
@@ -2247,6 +2255,7 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
return new Promise((resolve, reject) => {
const state = getState();
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables || {};
// Check if collection already exists in Redux state
const existingCollection = state.collections.collections.find(
@@ -2288,6 +2297,8 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
});
}
dispatch(workspaceEnvUpdateEvent({ processEnvVariables: workspaceProcessEnvVariables }));
resolve();
return;
}
@@ -2300,6 +2311,7 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
pathname: pathname,
items: [],
runtimeVariables: {},
workspaceProcessEnvVariables,
brunoConfig: brunoConfig
};
@@ -2318,6 +2330,9 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
);
if (currentWorkspace) {
// Set collection-workspace mapping for workspace env vars
ipcRenderer.invoke('renderer:set-collection-workspace', uid, currentWorkspace.pathname);
const alreadyInWorkspace = currentWorkspace.collections?.some(
(c) => normalizePath(c.path) === normalizePath(pathname)
);

View File

@@ -334,6 +334,9 @@ export const collectionsSlice = createSlice({
const activeEnvironment = findEnvironmentInCollection(collection, activeEnvironmentUid);
if (activeEnvironment) {
const existingEnvVarNames = new Set(Object.keys(envVariables));
// Update or add variables that exist in envVariables
forOwn(envVariables, (value, key) => {
const variable = find(activeEnvironment.variables, (v) => v.name === key);
const isPersistent = persistentEnvVariables && persistentEnvVariables[key] !== undefined;
@@ -369,6 +372,26 @@ export const collectionsSlice = createSlice({
}
}
});
// Handle variables that were deleted via bru.deleteEnvVar()
activeEnvironment.variables = activeEnvironment.variables.filter((variable) => {
// Variable still exists in envVariables after script execution - keep it
if (existingEnvVarNames.has(variable.name)) {
return true;
}
// Variable was deleted via bru.deleteEnvVar() - handle based on its state
// If variable was modified by script (has persistedValue), restore original value
if (variable.persistedValue !== undefined) {
variable.value = variable.persistedValue;
variable.ephemeral = false;
delete variable.persistedValue;
return true;
}
// Remove variable: either ephemeral (created by scripts) or non-ephemeral deleted via API
return false;
});
}
collection.runtimeVariables = runtimeVariables;
@@ -382,6 +405,12 @@ export const collectionsSlice = createSlice({
collection.processEnvVariables = processEnvVariables;
}
},
workspaceEnvUpdateEvent: (state, action) => {
const { processEnvVariables } = action.payload;
state.collections.forEach((collection) => {
collection.workspaceProcessEnvVariables = processEnvVariables;
});
},
requestCancelled: (state, action) => {
const { itemUid, collectionUid, seq, timestamp } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -2309,7 +2338,7 @@ export const collectionsSlice = createSlice({
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
if (!collection) return;
const folder = collection ? findItemInCollection(collection, action.payload.itemUid) : null;
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
if (!folder) return;
if (folder) {
@@ -3125,13 +3154,13 @@ export const collectionsSlice = createSlice({
const item = findItemInCollection(collection, itemUid);
if (data.data) {
item.response.data ||= [];
item.response.data = [{
item.response.data.push({
type: 'incoming',
seq,
message: data.data,
messageHexdump: hexdump(data.data),
timestamp: timestamp || Date.now()
}].concat(item.response.data);
});
}
if (item.response.dataBuffer && item.response.dataBuffer.length && data.dataBuffer) {
item.response.dataBuffer = Buffer.concat([Buffer.from(item.response.dataBuffer), Buffer.from(data.dataBuffer)]);
@@ -3401,6 +3430,7 @@ export const {
cloneItem,
scriptEnvironmentUpdateEvent,
processEnvUpdateEvent,
workspaceEnvUpdateEvent,
requestCancelled,
responseReceived,
runGrpcRequestEvent,

View File

@@ -169,8 +169,8 @@ export const removeCollectionFromWorkspaceAction = (workspaceUid, collectionPath
};
const loadWorkspaceCollectionsForSwitch = async (dispatch, workspace) => {
const openCollectionsFunction = (collectionPaths, workspaceId) => {
return dispatch(openMultipleCollections(collectionPaths, { workspaceId }));
const openCollectionsFunction = (collectionPaths, workspacePath) => {
return dispatch(openMultipleCollections(collectionPaths, { workspacePath }));
};
try {
@@ -418,7 +418,7 @@ export const workspaceConfigUpdatedEvent = (workspacePath, workspaceUid, workspa
if (uniqueNewCollectionPaths.length > 0) {
try {
await dispatch(openMultipleCollections(uniqueNewCollectionPaths, { workspaceId: workspace.pathname }));
await dispatch(openMultipleCollections(uniqueNewCollectionPaths, { workspacePath: workspace.pathname }));
} catch (error) {
}
}

View File

@@ -76,6 +76,14 @@ export const workspacesSlice = createSlice({
if (workspace) {
workspace.loadingState = loadingState;
}
},
workspaceDotEnvUpdateEvent: (state, action) => {
const { workspaceUid, processEnvVariables } = action.payload;
const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
if (workspace) {
workspace.processEnvVariables = processEnvVariables;
}
}
}
});
@@ -87,7 +95,8 @@ export const {
updateWorkspace,
addCollectionToWorkspace,
removeCollectionFromWorkspace,
updateWorkspaceLoadingState
updateWorkspaceLoadingState,
workspaceDotEnvUpdateEvent
} = workspacesSlice.actions;
export default workspacesSlice.reducer;

View File

@@ -241,7 +241,7 @@ const darkTheme = {
color: palette.text.BASE,
iconColor: palette.text.SUBTEXT2,
bg: palette.background.MANTLE,
hoverBg: palette.background.MANTLE,
hoverBg: palette.background.SURFACE0,
shadow: 'none',
border: palette.border.BORDER1,
separator: palette.border.BORDER1,

View File

@@ -10,10 +10,10 @@ const CALCULATION_DELAY_EXTENDED = 150;
const GAP_BETWEEN_LEFT_AND_RIGHT_CONTENT = 80;
const EXPANDABLE_HYSTERESIS = 20; // Buffer to prevent flickering at boundary
// Compare two tab arrays by their keys
const areTabArraysEqual = (a, b) => {
if (a.length !== b.length) return false;
return a.every((tab, index) => tab.key === b[index].key);
// Compare two key arrays for equality
const areKeysEqual = (prevKeys, newKeys) => {
if (prevKeys.length !== newKeys.length) return false;
return prevKeys.every((key, i) => key === newKeys[i]);
};
const ResponsiveTabs = ({
@@ -26,8 +26,8 @@ const ResponsiveTabs = ({
rightContentExpandedWidth, // Optional: width of the expandable element when expanded
expandableElementIndex = -1 // Optional: index of the expandable child element (-1 means last child)
}) => {
const [visibleTabs, setVisibleTabs] = useState([]);
const [overflowTabs, setOverflowTabs] = useState([]);
const [visibleTabKeys, setVisibleTabKeys] = useState([]);
const [overflowTabKeys, setOverflowTabKeys] = useState([]);
const [rightSideExpandable, setRightSideExpandable] = useState(false);
const tabsContainerRef = useRef(null);
@@ -79,9 +79,16 @@ const ResponsiveTabs = ({
}
}
// Only update state if arrays actually changed (prevents infinite loops)
setVisibleTabs((prev) => (areTabArraysEqual(prev, visible) ? prev : visible));
setOverflowTabs((prev) => (areTabArraysEqual(prev, overflow) ? prev : overflow));
// Extract keys and update state only if changed (prevents infinite loops)
const visibleKeys = visible.map((t) => t.key);
const overflowKeys = overflow.map((t) => t.key);
setVisibleTabKeys((prev) => {
return areKeysEqual(prev, visibleKeys) ? prev : visibleKeys;
});
setOverflowTabKeys((prev) => {
return areKeysEqual(prev, overflowKeys) ? prev : overflowKeys;
});
// Only calculate expandibility if rightContentExpandedWidth is provided
if (rightContentExpandedWidth && rightContentRef?.current) {
@@ -206,6 +213,10 @@ const ResponsiveTabs = ({
expandable: rightSideExpandable
});
// Map stored keys to fresh tab objects from props (ensures indicators stay up-to-date)
const visibleTabs = visibleTabKeys.map((key) => tabs.find((t) => t.key === key)).filter(Boolean);
const overflowTabs = overflowTabKeys.map((key) => tabs.find((t) => t.key === key)).filter(Boolean);
// Convert overflow tabs to MenuDropdown items format
const overflowMenuItems = useMemo(() => {
return overflowTabs.map((tab) => ({

View File

@@ -6,7 +6,7 @@
* LICENSE file at https://github.com/graphql/codemirror-graphql/tree/v0.8.3
*/
import { interpolate, mockDataFunctions } from '@usebruno/common';
import { interpolate, mockDataFunctions, timeBasedDynamicVars } from '@usebruno/common';
import { getVariableScope, isVariableSecret, getAllVariables } from 'utils/collections';
import { updateVariableInScope } from 'providers/ReduxStore/slices/collections/actions';
import store from 'providers/ReduxStore';
@@ -194,11 +194,13 @@ export const renderVarInfo = (token, options) => {
} else if (variableName.startsWith('$')) {
const fakerKeyword = variableName.substring(1); // Remove the $ prefix
const fakerFunction = mockDataFunctions[fakerKeyword];
const isTimeBased = timeBasedDynamicVars.has(fakerKeyword);
scopeInfo = {
type: 'dynamic',
value: '',
data: null,
isValidFakerVariable: !!fakerFunction
isValidDynamicVariable: !!fakerFunction,
isTimeBased
};
} else if (variableName.startsWith('process.env.')) {
// Check if this is a process.env variable (starts with "process.env.")
@@ -300,8 +302,8 @@ export const renderVarInfo = (token, options) => {
return into;
}
// Show warning for invalid faker variable (starts with $ but not a valid faker function)
if (scopeInfo.type === 'dynamic' && !scopeInfo.isValidFakerVariable) {
// Show warning for invalid dynamic variable (starts with $ but not a valid dynamic function)
if (scopeInfo.type === 'dynamic' && !scopeInfo.isValidDynamicVariable) {
const warningNote = document.createElement('div');
warningNote.className = 'var-warning-note';
warningNote.textContent = `Unknown dynamic variable "${variableName}". Check the variable name.`;
@@ -309,11 +311,13 @@ export const renderVarInfo = (token, options) => {
return into;
}
// For valid dynamic variables, just show the read-only note (no value display since it's generated at runtime)
if (scopeInfo.type === 'dynamic' && scopeInfo.isValidFakerVariable) {
// For valid dynamic variables, show appropriate read-only note based on type
if (scopeInfo.type === 'dynamic' && scopeInfo.isValidDynamicVariable) {
const readOnlyNote = document.createElement('div');
readOnlyNote.className = 'var-readonly-note';
readOnlyNote.textContent = 'Generates random value on each request';
readOnlyNote.textContent = scopeInfo.isTimeBased
? 'Generates current timestamp on each request'
: 'Generates random value on each request';
into.appendChild(readOnlyNote);
return into;
}

View File

@@ -8,8 +8,11 @@ jest.mock('@usebruno/common', () => ({
randomFirstName: jest.fn(() => 'John'),
randomLastName: jest.fn(() => 'Doe'),
randomEmail: jest.fn(() => 'john.doe@example.com'),
randomUUID: jest.fn(() => '123e4567-e89b-12d3-a456-426614174000')
}
randomUUID: jest.fn(() => '123e4567-e89b-12d3-a456-426614174000'),
timestamp: jest.fn(() => '1704067200'),
isoTimestamp: jest.fn(() => '2024-01-01T00:00:00.000Z')
},
timeBasedDynamicVars: new Set(['timestamp', 'isoTimestamp'])
}));
jest.mock('providers/ReduxStore', () => ({
@@ -467,6 +470,29 @@ describe('renderVarInfo', () => {
expect(warningNote).not.toBeNull();
expect(warningNote.textContent).toContain('Unknown dynamic variable');
});
it('should show time-based note for $timestamp variable', () => {
const { readOnlyNote, scopeBadge } = setupDynamicRender('$timestamp');
expect(scopeBadge.textContent).toBe('Dynamic');
expect(readOnlyNote).not.toBeNull();
expect(readOnlyNote.textContent).toBe('Generates current timestamp on each request');
});
it('should show time-based note for $isoTimestamp variable', () => {
const { readOnlyNote, scopeBadge } = setupDynamicRender('$isoTimestamp');
expect(scopeBadge.textContent).toBe('Dynamic');
expect(readOnlyNote).not.toBeNull();
expect(readOnlyNote.textContent).toBe('Generates current timestamp on each request');
});
it('should show random note for non-time-based dynamic variables', () => {
const { readOnlyNote } = setupDynamicRender('$randomEmail');
expect(readOnlyNote).not.toBeNull();
expect(readOnlyNote.textContent).toBe('Generates random value on each request');
});
});
describe('OAuth2 variable rendering', () => {

View File

@@ -59,7 +59,18 @@ function markUrls(editor, linkify, linkClass, linkHint) {
const matches = linkify.match(lineContent);
if (!matches) continue;
const variablePatterns = [];
const variablePattern = /\{\{[^}]*\}\}/g;
let varMatch;
while ((varMatch = variablePattern.exec(lineContent)) !== null) {
variablePatterns.push({ start: varMatch.index, end: varMatch.index + varMatch[0].length });
}
matches.forEach(({ index, lastIndex, url }) => {
const isInVariable = variablePatterns.some(
({ start, end }) => index < end && lastIndex > start
);
if (isInVariable) return;
try {
editor.markText(
{ line: lineNum, ch: index },

View File

@@ -99,6 +99,7 @@ export const exportCollection = (collection, version) => {
// delete process variables
delete collection.processEnvVariables;
delete collection.workspaceProcessEnvVariables;
deleteUidsInItems(collection.items);
deleteUidsInEnvs(collection.environments);

View File

@@ -1174,7 +1174,14 @@ export const getAllVariables = (collection, item) => {
const pathParams = getPathParams(item);
const { globalEnvironmentVariables = {} } = collection;
const { processEnvVariables = {}, runtimeVariables = {}, promptVariables = {} } = collection;
const { processEnvVariables = {}, runtimeVariables = {}, promptVariables = {}, workspaceProcessEnvVariables = {} } = collection;
// Merge workspace and collection processEnvVariables (collection takes priority)
const mergedProcessEnvVariables = {
...workspaceProcessEnvVariables,
...processEnvVariables
};
const mergedVariables = {
...folderVariables,
...requestVariables,
@@ -1216,7 +1223,7 @@ export const getAllVariables = (collection, item) => {
maskedEnvVariables: uniqueMaskedVariables,
process: {
env: {
...processEnvVariables
...mergedProcessEnvVariables
}
}
};

View File

@@ -506,12 +506,6 @@ export function prettifyJavaScriptString(jsString) {
}
};
// Check if string contains valid HTML structure
export const isValidHtml = (str) => {
if (typeof str !== 'string' || !str.trim()) return false;
return /<\s*html[\s>]/i.test(str);
};
export function formatHexView(buffer) {
const width = 16;
let output = '';

View File

@@ -12,24 +12,31 @@ const toPersistedEnvVarForMerge = (persistedNames) => (v) => {
return rest;
};
const isPersistableEnvVarForSave = (v) => {
if (!v) return false;
return !v.ephemeral || v.persistedValue !== undefined;
};
const toPersistedEnvVarForSave = (v) => {
const { ephemeral, persistedValue, ...rest } = v || {};
return v?.ephemeral ? (persistedValue !== undefined ? { ...rest, value: persistedValue } : rest) : rest;
};
/*
High-level builder for persisted variables
- mode 'save': write what the user sees
- mode 'merge': write only allowed vars (non-ephemeral, ephemerals with persistedValue, or explicitly persisted this run)
*/
// mode 'save': filters out ephemeral vars without persistedValue (script-created, never on disk)
// mode 'merge': same as 'save', but also includes ephemeral vars explicitly persisted this run
export const buildPersistedEnvVariables = (variables, { mode, persistedNames } = {}) => {
const src = Array.isArray(variables) ? variables : [];
if (mode === 'merge') {
const names = persistedNames instanceof Set ? persistedNames : new Set();
return src.filter(isPersistableEnvVarForMerge(names)).map(toPersistedEnvVarForMerge(names));
return src
.filter(isPersistableEnvVarForMerge(names))
.map(toPersistedEnvVarForMerge(names));
}
// default to save mode
return src.map(toPersistedEnvVarForSave);
return src
.filter(isPersistableEnvVarForSave)
.map(toPersistedEnvVarForSave);
};
export const buildEnvVariable = ({ envVariable: obj, withUuid = false }) => {

View File

@@ -1,6 +1,3 @@
import cloneDeep from 'lodash/cloneDeep';
import { resolvePath } from 'utils/filesystem';
export const sendNetworkRequest = async (item, collection, environment, runtimeVariables) => {
return new Promise((resolve, reject) => {
if (['http-request', 'graphql-request'].includes(item.type)) {
@@ -143,25 +140,10 @@ export const endGrpcStream = async (requestId) => {
};
export const loadGrpcMethodsFromProtoFile = async (filePath, collection = null) => {
return new Promise(async (resolve, reject) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
// Extract import paths from collection's gRPC config if available
let importPaths = [];
if (collection) {
const config = cloneDeep(collection.brunoConfig);
if (config.protobuf && config.protobuf.importPaths) {
// Use Promise.all to wait for all resolvePath calls to complete
const enabledImportPaths = config.protobuf.importPaths.filter((importPath) => importPath.enabled);
importPaths = await Promise.all(enabledImportPaths.map((importPath) => {
return resolvePath(importPath.path, collection.pathname);
}));
}
}
ipcRenderer.invoke('grpc:load-methods-proto', { filePath, includeDirs: importPaths }).then(resolve).catch(reject);
ipcRenderer.invoke('grpc:load-methods-proto', { filePath, collection }).then(resolve).catch(reject);
});
};

View File

@@ -93,81 +93,33 @@ const isLikelyText = (buffer) => {
};
/**
* Helper to detect if snippet is valid HTML
* Helper to detect SVG content from text buffer
* SVG files may start with XML declaration, comments, or whitespace before the <svg tag
* @param {Buffer} buffer - The data buffer to analyze
* @returns {boolean} - true if buffer contains SVG content
*/
export const isValidHtmlSnippet = (snippet) => {
if (!snippet || typeof snippet !== 'string') {
return false;
}
const isSvgContent = (buffer) => {
const length = buffer.length;
if (length < 4 || buffer[0] !== 0x3C) return false;
const trimmed = snippet.trim();
// Check for XML declaration
if (trimmed.startsWith('<?xml')) {
return false;
}
// Check for XML namespaces
if (/xmlns(:\w+)?=/.test(trimmed)) {
return false;
}
// Extract all tag names from the snippet
const tagMatches = trimmed.matchAll(/<\s*\/?([a-zA-Z][a-zA-Z0-9]*)/g);
const tags = [...tagMatches].map((match) => match[1].toLowerCase());
if (tags.length === 0) {
return false; // No tags found
}
// Define recognized HTML tags
const validHtmlTags = new Set([
'a', 'abbr', 'address', 'area', 'article', 'aside', 'audio',
'b', 'base', 'bdi', 'bdo', 'blockquote', 'body', 'br', 'button',
'canvas', 'caption', 'cite', 'code', 'col', 'colgroup',
'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
'em', 'embed',
'fieldset', 'figcaption', 'figure', 'footer', 'form',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html',
'i', 'iframe', 'img', 'input', 'ins',
'kbd',
'label', 'legend', 'li', 'link',
'main', 'map', 'mark', 'meta', 'meter',
'nav', 'noscript',
'object', 'ol', 'optgroup', 'option', 'output',
'p', 'param', 'picture', 'pre', 'progress',
'q',
'rp', 'rt', 'ruby',
's', 'samp', 'script', 'section', 'select', 'slot', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'svg',
'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
'u', 'ul',
'var', 'video',
'wbr'
]);
// Check if all tags are valid HTML tags
const allTagsValid = tags.every((tag) => validHtmlTags.has(tag));
if (!allTagsValid) {
return false; // Contains non-HTML tags
}
try {
// Parse with DOMParser
const parser = new DOMParser();
const doc = parser.parseFromString(trimmed, 'text/html');
// Check for parsing errors
const parseError = doc.querySelector('parsererror');
if (parseError) {
return false;
}
// HTML parser is lenient; if we reach here with valid tags, consider it valid
// Fast path: <svg
if (buffer[1] === 0x73 && buffer[2] === 0x76 && buffer[3] === 0x67) {
return true;
} catch (error) {
return false;
}
// Slow path: <?xml or <!DOCTYPE or <!--
if (buffer[1] !== 0x3F && buffer[1] !== 0x21) return false;
// Search for <svg in first 512 bytes
const limit = Math.min(512, length - 3);
for (let i = 2; i < limit; i++) {
if (buffer[i] === 0x3C && buffer[i + 1] === 0x73
&& buffer[i + 2] === 0x76 && buffer[i + 3] === 0x67) {
return true;
}
}
return false;
};
/**
@@ -237,6 +189,10 @@ export const detectContentTypeFromBuffer = (buffer) => {
if (bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
return 'image/webp';
}
if (bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70
&& bytes[8] === 0x61 && bytes[9] === 0x76 && bytes[10] === 0x69 && bytes[11] === 0x66) {
return 'image/avif';
}
if (bytes[0] === 0x42 && bytes[1] === 0x4D) {
return 'image/bmp';
}
@@ -247,7 +203,9 @@ export const detectContentTypeFromBuffer = (buffer) => {
if (bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00) {
return 'image/x-icon';
}
if (bytes[0] === 0x3C && bytes[1] === 0x73 && bytes[2] === 0x76 && bytes[3] === 0x67 && bytes[4] === 0x20) {
return 'image/svg+xml';
}
// PDF
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
return 'application/pdf';
@@ -315,6 +273,10 @@ export const detectContentTypeFromBase64 = (base64) => {
// 2. If not binary → decode up to 512 bytes for text detection
const textHead = decodeBase64Head(base64, 512);
if (isSvgContent(textHead)) {
return 'image/svg+xml';
}
if (isLikelyText(textHead)) return 'text/plain';
return null;

View File

@@ -15,8 +15,9 @@ const { rpad } = require('../utils/common');
const { getOptions } = require('../utils/bru');
const { parseDotEnv, parseEnvironment } = require('@usebruno/filestore');
const constants = require('../constants');
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack } = require('../utils/collection');
const { findItemInCollection, createCollectionJsonFromPathname, getCallStack, FORMAT_CONFIG } = require('../utils/collection');
const { hasExecutableTestInScript } = require('../utils/request');
const { createSkippedFileResults } = require('../utils/run');
const command = 'run [paths...]';
const desc = 'Run one or more requests/folders';
@@ -353,46 +354,63 @@ const handler = async function (argv) {
const runtimeVariables = {};
let envVars = {};
if (env && envFile) {
console.error(chalk.red(`Cannot use both --env and --env-file options together`));
process.exit(constants.EXIT_STATUS.ERROR_MALFORMED_ENV_OVERRIDE);
}
// Helper to load environment variables from a file
const loadEnvFromFile = (filePath, nameOverride) => {
const fileExt = path.extname(filePath).toLowerCase();
let result = {};
if (envFile || env) {
const envFilePath = envFile
? path.resolve(collectionPath, envFile)
: path.join(collectionPath, 'environments', `${env}.bru`);
const envFileExists = await exists(envFilePath);
if (!envFileExists) {
const errorPath = envFile || `environments/${env}.bru`;
console.error(chalk.red(`Environment file not found: `) + chalk.dim(errorPath));
process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND);
if (fileExt === '.json') {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(content);
const normalizedEnv = parseEnvironmentJson(parsed);
result = getEnvVars(normalizedEnv);
const rawName = normalizedEnv?.name;
const trimmedName = typeof rawName === 'string' ? rawName.trim() : '';
result.__name__ = trimmedName || path.basename(filePath, '.json');
} else if (fileExt === '.yml' || fileExt === '.yaml') {
const content = fs.readFileSync(filePath, 'utf8');
const envJson = parseEnvironment(content, { format: 'yml' });
result = getEnvVars(envJson);
result.__name__ = nameOverride || path.basename(filePath, fileExt);
} else {
const content = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
const envJson = parseEnvironment(content);
result = getEnvVars(envJson);
result.__name__ = nameOverride || path.basename(filePath, '.bru');
}
const ext = path.extname(envFilePath).toLowerCase();
if (ext === '.json') {
// Parse Bruno schema JSON environment
let envJsonContent;
try {
envJsonContent = fs.readFileSync(envFilePath, 'utf8');
const parsed = JSON.parse(envJsonContent);
const normalizedEnv = parseEnvironmentJson(parsed);
envVars = getEnvVars(normalizedEnv);
const rawName = normalizedEnv?.name;
const trimmedName = typeof rawName === 'string' ? rawName.trim() : '';
envVars.__name__ = trimmedName || path.basename(envFilePath, '.json');
} catch (err) {
console.error(chalk.red(`Failed to parse Environment JSON: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
}
} else {
// Default to .bru parsing
const envBruContent = fs.readFileSync(envFilePath, 'utf8').replace(/\r\n/g, '\n');
const envJson = parseEnvironment(envBruContent);
envVars = getEnvVars(envJson);
envVars.__name__ = envFile ? path.basename(envFilePath, '.bru') : env;
return result;
};
// Load --env-file if provided
if (envFile) {
const envFilePath = path.resolve(collectionPath, envFile);
if (!(await exists(envFilePath))) {
console.error(chalk.red(`Environment file not found: `) + chalk.dim(envFile));
process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND);
}
try {
envVars = loadEnvFromFile(envFilePath);
} catch (err) {
console.error(chalk.red(`Failed to parse environment file: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
}
}
// Load --env and merge (collection env takes precedence)
if (env) {
const envExt = FORMAT_CONFIG[collection.format].ext;
const collectionEnvFilePath = path.join(collectionPath, 'environments', `${env}${envExt}`);
if (!(await exists(collectionEnvFilePath))) {
console.error(chalk.red(`Environment file not found: `) + chalk.dim(`environments/${env}${envExt}`));
process.exit(constants.EXIT_STATUS.ERROR_ENV_NOT_FOUND);
}
try {
const collectionEnvVars = loadEnvFromFile(collectionEnvFilePath, env);
envVars = { ...envVars, ...collectionEnvVars };
} catch (err) {
console.error(chalk.red(`Failed to parse Environment file: ${err.message}`));
process.exit(constants.EXIT_STATUS.ERROR_INVALID_FILE);
}
}
@@ -596,10 +614,11 @@ const handler = async function (argv) {
const runtime = getJsSandboxRuntime(sandbox);
const runSingleRequestByPathname = async (relativeItemPathname) => {
const ext = FORMAT_CONFIG[collection.format].ext;
return new Promise(async (resolve, reject) => {
let itemPathname = path.join(collectionPath, relativeItemPathname);
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
if (itemPathname && !itemPathname?.endsWith(ext)) {
itemPathname = `${itemPathname}${ext}`;
}
const requestItem = cloneDeep(findItemInCollection(collection, itemPathname));
if (requestItem) {
@@ -656,7 +675,8 @@ const handler = async function (argv) {
...result,
runDuration: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
suitename: pathname.replace('.bru', ''),
name
name,
path: result.test?.filename || path.relative(collectionPath, pathname)
});
if (reporterSkipAllHeaders) {
@@ -729,6 +749,9 @@ const handler = async function (argv) {
}
}
const skippedFileResults = createSkippedFileResults(global.brunoSkippedFiles || [], collectionPath);
results.push(...skippedFileResults);
const summary = printRunSummary(results);
const runCompletionTime = new Date().toISOString();
const totalTime = results.reduce((acc, res) => acc + res.response.responseTime, 0);

View File

@@ -10,7 +10,8 @@ const getContentType = (headers = {}) => {
}
});
return contentType;
// Return empty string if contentType is not a string (e.g., null/false for no body requests)
return typeof contentType === 'string' ? contentType : '';
};
const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, processEnvVars = {}) => {

View File

@@ -288,13 +288,16 @@ const prepareRequest = async (item = {}, collection = {}) => {
request.body = request.body || {};
if (request.body.mode === 'json') {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json';
}
try {
axiosRequest.data = decomment(request?.body?.json);
} catch (error) {
axiosRequest.data = request?.body?.json;
const jsonBody = request.body.json;
if (jsonBody && jsonBody.length > 0) {
if (!contentTypeDefined) {
axiosRequest.headers['content-type'] = 'application/json';
}
try {
axiosRequest.data = decomment(jsonBody);
} catch (error) {
axiosRequest.data = jsonBody;
}
}
}
@@ -376,6 +379,17 @@ const prepareRequest = async (item = {}, collection = {}) => {
axiosRequest.data = graphqlQuery;
}
// if the mode is 'none' then set the content-type header to null to prevent axios from adding default. #1693
// AWS SigV4 requires Content-Type header in canonical request for signature calculation,
// even with no body. Omitting it would cause authentication failures.
if (request.body.mode === 'none' && (!request.auth || request.auth.mode !== 'awsv4')) {
if (!contentTypeDefined) {
// Setting to null tells axios not to add a default Content-Type header
// Use lowercase to match what scripts use, avoiding duplicate headers
axiosRequest.headers['content-type'] = null;
}
}
if (request.script) {
axiosRequest.script = request.script;
}

View File

@@ -7,116 +7,90 @@ const { parseRequest, parseCollection, parseFolder, stringifyCollection, stringi
const constants = require('../constants');
const chalk = require('chalk');
const createCollectionJsonFromPathname = (collectionPath) => {
const environmentsPath = path.join(collectionPath, `environments`);
// get the collection bruno json config [<collection-path>/bruno.json]
const brunoConfig = getCollectionBrunoJsonConfig(collectionPath);
// get the collection root [<collection-path>/collection.bru]
const collectionRoot = getCollectionRoot(collectionPath);
// get the collection items recursively
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (stats.isDirectory()) {
if (filePath === environmentsPath) continue;
if (filePath.startsWith('.git') || filePath.startsWith('node_modules')) continue;
// get the folder root
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) };
const folderBruJson = getFolderRoot(filePath);
if (folderBruJson) {
folderItem.root = folderBruJson;
folderItem.seq = folderBruJson.meta.seq;
}
currentDirItems.push(folderItem);
} else {
if (['collection.bru', 'folder.bru'].includes(file)) continue;
if (path.extname(filePath) !== '.bru') continue;
// get the request item
try {
const bruContent = fs.readFileSync(filePath, 'utf8');
const requestItem = parseRequest(bruContent);
currentDirItems.push({
name: file,
pathname: filePath,
...requestItem
});
} catch (err) {
// Log warning for invalid .bru file but continue processing
console.warn(chalk.yellow(`Warning: Skipping invalid file ${filePath}\nError: ${err.message}`));
// Track skipped files for later reporting
if (!global.brunoSkippedFiles) {
global.brunoSkippedFiles = [];
}
global.brunoSkippedFiles.push({ path: filePath, error: err.message });
}
}
}
let currentDirFolderItems = currentDirItems?.filter((iter) => iter.type === 'folder');
let sortedFolderItems = sortByNameThenSequence(currentDirFolderItems);
let currentDirRequestItems = currentDirItems?.filter((iter) => iter.type !== 'folder');
let sortedRequestItems = currentDirRequestItems?.sort((a, b) => a.seq - b.seq);
return sortedFolderItems?.concat(sortedRequestItems);
};
let collectionItems = traverse(collectionPath);
let collection = {
brunoConfig,
root: collectionRoot,
pathname: collectionPath,
items: collectionItems
};
return collection;
const FORMAT_CONFIG = {
yml: { ext: '.yml', collectionFile: 'opencollection.yml', folderFile: 'folder.yml' },
bru: { ext: '.bru', collectionFile: 'collection.bru', folderFile: 'folder.bru' }
};
const getCollectionBrunoJsonConfig = (dir) => {
// right now, bru must be run from the root of the collection
// will add support in the future to run it from anywhere inside the collection
const brunoJsonPath = path.join(dir, 'bruno.json');
const brunoJsonExists = fs.existsSync(brunoJsonPath);
if (!brunoJsonExists) {
const getCollectionFormat = (collectionPath) => {
if (fs.existsSync(path.join(collectionPath, 'opencollection.yml'))) return 'yml';
if (fs.existsSync(path.join(collectionPath, 'bruno.json'))) return 'bru';
return null;
};
const getCollectionConfig = (collectionPath, format) => {
if (format === 'yml') {
const content = fs.readFileSync(path.join(collectionPath, 'opencollection.yml'), 'utf8');
const parsed = parseCollection(content, { format: 'yml' });
return { brunoConfig: parsed.brunoConfig, collectionRoot: parsed.collectionRoot || {} };
}
const brunoConfig = JSON.parse(fs.readFileSync(path.join(collectionPath, 'bruno.json'), 'utf8'));
const collectionBruPath = path.join(collectionPath, 'collection.bru');
const collectionRoot = fs.existsSync(collectionBruPath)
? parseCollection(fs.readFileSync(collectionBruPath, 'utf8'), { format: 'bru' })
: {};
return { brunoConfig, collectionRoot };
};
const getFolderRoot = (dir, format) => {
const folderPath = path.join(dir, FORMAT_CONFIG[format].folderFile);
if (!fs.existsSync(folderPath)) return null;
return parseFolder(fs.readFileSync(folderPath, 'utf8'), { format });
};
const createCollectionJsonFromPathname = (collectionPath) => {
const format = getCollectionFormat(collectionPath);
if (!format) {
console.error(chalk.red(`You can run only at the root of a collection`));
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
}
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoConfigFile);
return brunoConfig;
};
const { brunoConfig, collectionRoot } = getCollectionConfig(collectionPath, format);
const { ext, collectionFile, folderFile } = FORMAT_CONFIG[format];
const environmentsPath = path.join(collectionPath, 'environments');
const getCollectionRoot = (dir) => {
const collectionRootPath = path.join(dir, 'collection.bru');
const exists = fs.existsSync(collectionRootPath);
if (!exists) {
return {};
}
const traverse = (currentPath) => {
if (currentPath.includes('node_modules')) return [];
const currentDirItems = [];
const content = fs.readFileSync(collectionRootPath, 'utf8');
return parseCollection(content);
};
for (const file of fs.readdirSync(currentPath)) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
const getFolderRoot = (dir) => {
const folderRootPath = path.join(dir, 'folder.bru');
const exists = fs.existsSync(folderRootPath);
if (!exists) {
return null;
}
if (stats.isDirectory()) {
if (filePath === environmentsPath || file === '.git' || file === 'node_modules') continue;
const folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) };
const folderRoot = getFolderRoot(filePath, format);
if (folderRoot) {
folderItem.root = folderRoot;
folderItem.seq = folderRoot.meta?.seq;
}
currentDirItems.push(folderItem);
} else {
if (file === collectionFile || file === folderFile || path.extname(filePath) !== ext) continue;
try {
const requestItem = parseRequest(fs.readFileSync(filePath, 'utf8'), { format });
currentDirItems.push({ name: file, ...requestItem, pathname: filePath });
} catch (err) {
console.warn(chalk.yellow(`Warning: Skipping invalid file ${filePath}\nError: ${err.message}`));
global.brunoSkippedFiles = global.brunoSkippedFiles || [];
global.brunoSkippedFiles.push({ path: filePath, error: err.message });
}
}
}
const content = fs.readFileSync(folderRootPath, 'utf8');
return parseFolder(content);
const folders = sortByNameThenSequence(currentDirItems.filter((i) => i.type === 'folder'));
const requests = currentDirItems.filter((i) => i.type !== 'folder').sort((a, b) => a.seq - b.seq);
return folders.concat(requests);
};
return {
brunoConfig,
format,
root: collectionRoot,
pathname: collectionPath,
items: traverse(collectionPath)
};
};
const mergeHeaders = (collection, request, requestTreePath) => {
@@ -612,6 +586,8 @@ const sortByNameThenSequence = (items) => {
};
module.exports = {
FORMAT_CONFIG,
getCollectionFormat,
createCollectionJsonFromPathname,
mergeHeaders,
mergeVars,

View File

@@ -0,0 +1,40 @@
const path = require('path');
const { stripExtension } = require('./filesystem');
const createSkippedFileResults = (skippedFiles, collectionPath) => {
return skippedFiles.map((skippedFile) => {
const relativePath = path.relative(collectionPath, skippedFile.path);
return {
test: {
filename: relativePath
},
request: {
method: null,
url: null,
headers: null,
data: null
},
response: {
status: 'skipped',
statusText: skippedFile.error,
data: null,
responseTime: 0
},
error: skippedFile.error,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults: [],
postResponseTestResults: [],
runDuration: 0,
suitename: stripExtension(relativePath),
name: path.basename(skippedFile.path),
path: relativePath
};
});
};
module.exports = {
createSkippedFileResults
};

View File

@@ -1,7 +1,9 @@
const path = require('node:path');
const fs = require('node:fs');
const { describe, it, expect } = require('@jest/globals');
const constants = require('../../src/constants');
const { createCollectionJsonFromPathname } = require('../../src/utils/collection');
const { createCollectionJsonFromPathname, getCollectionFormat, FORMAT_CONFIG } = require('../../src/utils/collection');
const { parseEnvironment } = require('@usebruno/filestore');
describe('create collection json from pathname', () => {
it('should throw an error when the pathname is not a valid bruno collection root', () => {
@@ -169,4 +171,96 @@ describe('create collection json from pathname', () => {
// tests
expect(c).toHaveProperty('items[4].request.tests', 'test(\"request level script\", function() {\n expect(\"test\").to.equal(\"test\");\n});');
});
it('creates a collection json from OpenCollection yml files', () => {
const collectionPathname = path.join(__dirname, './fixtures/opencollection/collection');
const c = createCollectionJsonFromPathname(collectionPathname);
expect(c).toBeDefined();
expect(c).toHaveProperty('format', 'yml');
expect(c).toHaveProperty('brunoConfig.opencollection', '1.0.0');
expect(c).toHaveProperty('brunoConfig.name', 'Test OpenCollection');
expect(c).toHaveProperty('brunoConfig.type', 'collection');
expect(c).toHaveProperty('brunoConfig.ignore', ['node_modules', '.git']);
expect(c).toHaveProperty('pathname', collectionPathname);
// collection root headers
expect(c).toHaveProperty('root.request.headers[0].name', 'X-Collection-Header');
expect(c).toHaveProperty('root.request.headers[0].value', 'collection-header-value');
expect(c).toHaveProperty('root.request.headers[0].enabled', true);
// folder
expect(c.items.some((i) => i.type === 'folder' && i.name === 'users')).toBe(true);
const usersFolder = c.items.find((i) => i.name === 'users');
expect(usersFolder).toHaveProperty('root.meta.name', 'Users');
expect(usersFolder).toHaveProperty('root.meta.seq', 1);
expect(usersFolder.pathname).toContain('users');
// request in folder - name comes from info.name, pathname is correct
const createUserReq = usersFolder.items.find((i) => i.name === 'Create User');
expect(createUserReq).toBeDefined();
expect(createUserReq).toHaveProperty('type', 'http-request');
expect(createUserReq).toHaveProperty('request.method', 'POST');
expect(createUserReq).toHaveProperty('request.url', 'https://api.example.com/users');
expect(createUserReq.pathname).toContain('create-user.yml');
// root level request - name comes from info.name, pathname is correct
const getUsersReq = c.items.find((i) => i.name === 'Get Users');
expect(getUsersReq).toBeDefined();
expect(getUsersReq).toHaveProperty('type', 'http-request');
expect(getUsersReq).toHaveProperty('request.method', 'GET');
expect(getUsersReq).toHaveProperty('request.url', 'https://api.example.com/users');
expect(getUsersReq.pathname).toContain('get-users.yml');
});
});
describe('getCollectionFormat', () => {
it('returns yml for OpenCollection', () => {
const collectionPath = path.join(__dirname, './fixtures/opencollection/collection');
expect(getCollectionFormat(collectionPath)).toBe('yml');
});
it('returns bru for Bruno collection', () => {
const collectionPath = path.join(__dirname, './fixtures/collection-json-from-pathname/collection');
expect(getCollectionFormat(collectionPath)).toBe('bru');
});
it('returns null for invalid path', () => {
const collectionPath = path.join(__dirname, './fixtures/collection-invalid');
expect(getCollectionFormat(collectionPath)).toBe(null);
});
});
describe('FORMAT_CONFIG', () => {
it('has correct config for yml format', () => {
expect(FORMAT_CONFIG.yml).toEqual({
ext: '.yml',
collectionFile: 'opencollection.yml',
folderFile: 'folder.yml'
});
});
it('has correct config for bru format', () => {
expect(FORMAT_CONFIG.bru).toEqual({
ext: '.bru',
collectionFile: 'collection.bru',
folderFile: 'folder.bru'
});
});
});
describe('OpenCollection environment parsing', () => {
it('parses YML environment files correctly', () => {
const envPath = path.join(__dirname, './fixtures/opencollection/collection/environments/dev.yml');
const envContent = fs.readFileSync(envPath, 'utf8');
const env = parseEnvironment(envContent, { format: 'yml' });
expect(env).toBeDefined();
expect(env).toHaveProperty('name', 'Development');
expect(env.variables).toHaveLength(2);
expect(env.variables[0]).toHaveProperty('name', 'baseUrl');
expect(env.variables[0]).toHaveProperty('value', 'https://api.dev.example.com');
expect(env.variables[1]).toHaveProperty('name', 'apiKey');
expect(env.variables[1]).toHaveProperty('value', 'dev-api-key-123');
});
});

View File

@@ -0,0 +1,6 @@
name: Development
variables:
- name: baseUrl
value: https://api.dev.example.com
- name: apiKey
value: dev-api-key-123

View File

@@ -0,0 +1,8 @@
info:
name: Get Users
type: http
seq: 1
http:
method: GET
url: https://api.example.com/users

View File

@@ -0,0 +1,14 @@
opencollection: "1.0.0"
info:
name: Test OpenCollection
extensions:
ignore:
- node_modules
- .git
request:
headers:
- name: X-Collection-Header
value: collection-header-value
enabled: true

View File

@@ -0,0 +1,14 @@
info:
name: Create User
type: http
seq: 1
http:
method: POST
url: https://api.example.com/users
body:
mode: json
json: |
{
"name": "John Doe"
}

View File

@@ -0,0 +1,3 @@
info:
name: Users
seq: 1

View File

@@ -49,4 +49,65 @@ describe('HTML Report Generation', () => {
expect(htmlString).toContain('{{ totalDataReceived }}');
expect(htmlString).toContain('{{ averageResponseTime }}');
});
it('should include skipped requests with parsing errors in the HTML report', async () => {
const mockResults = [
{
iterationIndex: 0,
results: [
{
test: {
filename: 'invalid-request.bru'
},
request: {
method: null,
url: null,
headers: null,
data: null
},
response: {
status: 'skipped',
statusText: 'Unexpected token',
data: null,
responseTime: 0
},
error: 'Unexpected token',
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
preRequestTestResults: [],
postResponseTestResults: [],
name: 'invalid-request.bru',
path: 'invalid-request.bru',
runDuration: 0
}
],
summary: {
totalRequests: 1,
passedRequests: 0,
failedRequests: 0,
errorRequests: 0,
skippedRequests: 1,
totalAssertions: 0,
passedAssertions: 0,
failedAssertions: 0,
totalTests: 0,
passedTests: 0,
failedTests: 0
}
}
];
const htmlString = generateHtmlReport({
runnerResults: mockResults,
version: 'usebruno v1.16.0',
environment: null,
runCompletionTime: '2024-01-15T14:30:45.123Z'
});
expect(htmlString).toContain('Request Skipped');
expect(htmlString).toContain('summarySkippedRequests');
expect(htmlString).toContain('result.response.status === \'skipped\'');
});
});

Some files were not shown because too many files have changed in this diff Show More