Compare commits

..

201 Commits

Author SHA1 Message Date
Chirag Chandrashekhar
fa1498e2a8 Bugfix/incorrect space encode (#5870)
* Fix the space encoding issue

* fix: incorrect space encoding

Fixed an issue in Code Generation for requests. The original fix was
raised in [PR](https://github.com/usebruno/bruno/pull/4478). The current
PR fixes some merge conflicts and resolves some unimported dependencies
error.

* test: add URL encoding tests for code generation feature

Add Playwright tests to verify proper URL encoding behavior in Bruno's
code generation dialog for both encoded and unencoded query parameters.

* moved the test script inside request

* updated the snippet generation code to reuse code and reduce redundancy

* removed redundant code and reverted autoformat

* reverted some auto formatted changes

* reverting format during commit hook

* chore: reset formatting

* chore: reformat

---------

Co-authored-by: Vipin Sundar <86339268+vipin-sundar@users.noreply.github.com>
Co-authored-by: Chirag Chandrashekhar <chiragchan@Chirags-MacBook-Air.local>
Co-authored-by: Sid <siddharth@usebruno.com>

chore: reformat
2025-10-24 16:29:22 +05:30
Vipin Sundar
045141efaf Fix the space encoding issue (#4478) 2025-10-23 15:27:19 +05:30
anusree-bruno
c997b91698 added jsonwebtoken as inbuilt library (#5535)
* added jsonwebtoken as inbuilt library

* removed bundling

* handle callback in quickjs

* chore: tests folder restructure

* chore: lint fix

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-22 14:57:19 +05:30
anusree-bruno
986d5b0b2a moved custom search to components folder (#5750)
* moved custom search to components folder

* renamed custom search

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-22 14:56:57 +05:30
ganesh
a2a521477a add fix for runtime var color (#4254)
* added new changes

* adds color to light and dark theme file

* import theme obj and use variable runtime color

* fix: operator linebreak style for eslint

* chore: remove un-needed changes

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-22 14:23:12 +05:30
Siddharth Gelera (reaper)
8e70adcbf9 fix: incomplete tests (#5824)
* fix: close support modal for other tests to reuse the window properly

* Update support-links.spec.js

* chore: reformat

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-22 13:39:36 +05:30
Prasanth Baskar
87296776fa add arch linux install to readme (#4569)
Signed-off-by: bupd <bupdprasanth@gmail.com>
2025-10-19 03:19:16 +05:30
Alex
9df70cd759 Merge pull request #5809 from 0x416c6578/feature/minify-json-xml
Add `bru.utils.minifyXml` and `bru.utils.minifyJson`
2025-10-19 01:29:32 +05:30
Anoop M D
8f9fb3b3c9 Merge pull request #5163 from josbiz/fix--dot-on-proxy-options-when-unused
fix: dot on unused proxy settings
2025-10-19 01:15:21 +05:30
Anoop M D
6d018f5648 Merge pull request #5164 from josbiz/fix--dot-now-showed-on-used-preset-setting
fix: show dot on used preset setting
2025-10-19 01:14:00 +05:30
Anoop M D
789d0b23c0 added option to revert changes (#4503) 2025-10-19 01:09:07 +05:30
anusree-bruno
81e1e403e4 handle options in getBody for QuickJS VM (#4614) 2025-10-19 01:02:30 +05:30
Sanjai Kumar
ad2add4026 Added tests for replacing invalid variable characters in Postman collection Env (#4634)
---------

Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
Co-authored-by: Anoop M D <anoop@usebruno.com>
2025-10-19 01:00:19 +05:30
naman-bruno
02554c3ad9 Merge pull request #4279 from naman-bruno/feat/apikey-codegen
Add: API Key auth in code generator
2025-10-19 00:53:58 +05:30
Anoop M D
62815e3429 Merge pull request #5008 from anusree-bruno/feat/add-process-env-vars-to-gql-introspection
add process.env variable support to GraphQL introspection
2025-10-18 23:15:51 +05:30
Anoop M D
9859b69559 Merge pull request #5113 from apealpha/bugfix/3019-prettify-json-with-variables
fix(request): prettify JSON with variables
2025-10-18 22:51:10 +05:30
Anoop M D
440c688bbb Merge pull request #4708 from pooja-bruno/improve/use-common-getTreePathFromCollectionToItem-function
improve: use common getTreePathFromCollectionToItem function
2025-10-18 22:44:08 +05:30
Anoop M D
416eb754b7 Merge pull request #4747 from ZieglerZhu/bugfix/update-readme-cn
docs(readme): update readme_cn.md
2025-10-18 18:00:11 +05:30
Anoop M D
b85d6efa60 Merge pull request #5303 from usebruno/dependabot/github_actions/actions/checkout-5
build(deps): bump actions/checkout from 4 to 5
2025-10-18 16:49:35 +05:30
Blake Guilloud
19dea18629 Merge pull request #5829 from BlakeGuilloud/bugfix/5823-saving-url-in-response-pane
Bugfix/5823 saving url in response pane
2025-10-18 16:39:21 +05:30
Abhishek S Lal
636901c23d fix: resolve global env variable becoming undefined on script execution (#5816)
* fix: resolve global env variable becoming undefined on script execution

Fixes an issue where global disabled environment variables were becoming undefined during request execution when the pre request script is non-empty.

The update ensures that global variables persist as expected and are correctly referenced throughout the request lifecycle.

Closes #5772.

* feat: added test for checking proper global env update through scripts

* refactor: updated comments for more readability and added a new data-testid in modal.
2025-10-17 21:50:50 +05:30
lohit
a4b1941817 fix(bru-2035): form-urlencoded logic updates (#5820) 2025-10-17 18:22:43 +05:30
Sid
7d8fde9180 fix: improve URL parsing in getParsedWsUrlObject (#5822) 2025-10-17 18:15:15 +05:30
Anoop M D
4197304bf9 Merge pull request #5679 from mheidinger/visual-gql-indicator
feat: add visual indicator for GQL requests
2025-10-17 15:00:37 +05:30
Max Heidinger
b75422a010 feat: add visual indicator for GQL requests 2025-10-17 10:25:54 +02:00
Pragadesh-45
e9f03c46c7 tests: add tests for URN parsing (#5819) 2025-10-17 10:58:26 +05:30
Pragadesh-45
73e828621f fix: enhance URL parameter parsing and interpolation logic (#5812)
* fix: enhance URL parameter parsing and interpolation logic
2025-10-16 17:58:53 +05:30
Siddharth Gelera (reaper)
2becf49542 fix: harden type checks for buildFormUrlEncodedPayload (#5811) 2025-10-16 13:31:13 +05:30
Siddharth Gelera (reaper)
4c3a9928bc fix: remove redundant ipcRenderer.invoke call (#5799) 2025-10-15 17:33:41 +05:30
sanish chirayath
b694a41c96 fix: duplicate response for grpc (#5793) 2025-10-15 16:39:37 +05:30
sanish chirayath
ff9a4d97e3 fix: newly created requests should be added within the directory context (#5784)
* fix: newly created requests should get added to directory we want them to get added

* refactor: simplify code

* fix : lint

* refactor

* refactor
2025-10-14 17:49:21 +05:30
Bijin A B
6ab6e5ed57 fix(ui): limit dropdown width to 650px and fix alignment (#5781) 2025-10-14 12:05:48 +05:30
Bijin A B
3837a7612c Merge pull request #5778 from bijin-bruno/fix/environment-names-visibility
fix: make environment name width flexible up to 35% and disable tooltip for short names
2025-10-14 11:31:29 +05:30
Anoop M D
6589dc51cd Merge pull request #5765 from usebruno/chore/better-message-for-the-future-maintainer
chore(#1693): better comment explaining why bruno sets content-type header as false
2025-10-12 15:19:37 +05:30
Anoop M D
509f4da667 chore(#1693): better comment explaining why bruno sets content-type header as false 2025-10-12 15:18:29 +05:30
Anoop M D
9d2b070ed9 Merge pull request #5754 from Pragadesh-45/fix/doc-editor
feat(Markdown): override normalizing on whitespace in markdown editor
2025-10-11 17:25:39 +05:30
Anoop M D
d0c524cd9a Merge pull request #5757 from wbw1537/enhance-error-log
bugfix/Enhance error log for OAuth2 when certificate error
2025-10-11 17:08:43 +05:30
Anoop M D
74f0f67795 Update error message for SSL/TLS certificate verification 2025-10-11 17:08:09 +05:30
Bowen Wang
45664bdb65 enhance error log 2025-10-10 21:55:53 +08:00
Pragadesh-45
98cb2df3fe feat(Markdown): enhance Markdown rendering options and use exact whitespace instead normalizing 2025-10-09 23:33:55 +05:45
lohit
d478102b30 chore(bru-1943): upgrade electron version to v37.6.1 (#5752) 2025-10-09 18:58:28 +05:30
Siddharth Gelera (reaper)
924bc2e79e Merge pull request #5713 from barelyhuman/fix/form-values-seq-5237
fix: reimplement payload serialization for `x-www-form-encoded`
2025-10-09 18:25:28 +05:30
Pooja
c2d40fe99f Fix: Cross button not resetting timeout to inherit (#5749)
* Fix: Cross button not resetting timeout to inherit
2025-10-09 14:47:18 +05:30
sanish chirayath
944674d208 feat: add transformDescription function to handle various description formats in Postman collections (#5744)
- Implemented transformDescription to standardize handling of string and object descriptions.
- Updated importPostmanV2CollectionItem to utilize transformDescription for folder, request, and parameter descriptions.
- Added comprehensive tests for transformDescription covering edge cases and different formats.
2025-10-08 20:31:39 +05:30
Pooja
0c30357b01 feat: add redirect and timeout in request settings (#5672)
* feat: add redirect and timeout in request settings
2025-10-08 20:00:37 +05:30
Sanjai Kumar
ce40949564 fix: filter out internal content-type headers for no body requests in axiosinstance (#5591)
* fix: filter out internal content-type headers for no body requests in axios instance
2025-10-08 17:25:21 +05:30
Siddharth Gelera (reaper)
c6ce40c245 fix: keepAlive's fallback adds problem while saving normal requests (#5741)
* fix: only get the values if the settings exist
* Apply suggestion from @Copilot
* refactor: move status line to the query bar

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-08 17:06:24 +05:30
Anoop M D
6890bbee70 Merge pull request #5733 from Skewnart/fix/locale-usage-in-tests
fix: fixing tests using locale on numbers
2025-10-08 15:01:16 +05:30
lohit
4993c61e29 fix(bru-1928): fix debug library dependency for bruno-requests package (#5738) 2025-10-08 12:59:33 +05:30
anusree-bruno
a66e849cfb Feat/editor custom search (#5278)
* added custom search in editor

* UI improvements

* added yellow highlight for search

* added playwright tests

* memoizing matches and few other changes

* fixed issue with debounce

* refactoring and styling fixes

* lint fixes

* ensure ESC closes search bar even when focus is in editor

* move esc logic to editor

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-08 11:05:17 +05:30
lohit
9f47200e7b fix(bru-1939): fix OAuth2 credentials not persisting across requests in the same collection run. (#5730) 2025-10-07 22:40:51 +05:30
lohit
10739c32c4 fix(bru-1928): bruno-cli oauth2 updates (#5729) 2025-10-07 22:38:52 +05:30
Siddharth Gelera (reaper)
c1853e613b Feat: Websocket Support (#5480)
* init

* fix: header saving in ws

* fix: retrieve auth value correctly

* feat: ws settings

* fix: text for inherited auth

* feat: pass down options/settings for ws

* fix: handle run handling on url

* fix: send initial message

* fix: fix header movement and minor cleanup

* fix: message queue

* refactor: faster flushing

* feat: ws tab specific additions

close tab should close connection
`ws` shown in the tab

* chore: remove unused icon

* feat: simplify query URL rendering

* fix: only add to settings if they were added

* chore: revert to original

* fix: restyle web ui

* feat: implement WebSocket response sorting and enhance message handling

- Added WSResponseSortOrder component for toggling message sort order.
- Updated WSMessagesList to accept and utilize sort order.
- Refactored message handling to use 'type' instead of 'direction'.
- Enhanced response state management to include sort order.

* feat: enhance WebSocket handling with redirect and upgrade events

- Added support for 'ws:redirect' and 'ws:upgrade' events in the WebSocket client.
- Updated WSResponseHeaders to format headers correctly.
- Modified WSResponsePane to display headers in the response.
- Improved message handling in the Redux slice for WebSocket events.

* fix: correct fallback for URL retrieval in bruRequestToJson

* feat: enhance WebSocket message handling and styling

- Add new styling for incoming messages in StyledWrapper.
- Update WSMessagesList to handle message sorting and focus.
- Refactor response sort order handling in WSResponseSortOrder.
- Improve WebSocket connection management in ws-client.

* fix: adjust styling for message display

* fix: imports for ws files

* fix: visually simplify the message list

* chore: pkg updates

* fix: remove unused content-type check in WebSocket request preparation

* fix: avoid duplicate messages

avoid message getting queued and sent twice

* feat: beautify the code editor in each message

* feat(websockets): add websocket tests

* tests(websockets): move it a folder up

* fix: hexdump on sent messages

* fix: make the view a lot more compact

* feat: enhance WebSocket message handling and styling

* formatting fixes - batch 1

* chore: formatting fixes batch 2

* chore: format changes batch 3

* chore: format settings batch 4

* chore: clean up

* chore: for now avoid oauth2

* chore: formatting changes batch 6

* test(websocket): add headers handling in tests

- Implemented logic to send headers in websocket messages.
- Added tests for websocket connections and message handling.
- Created locators for common elements in websocket tests.

* chore: cleanup

* test(websocket): refactor to use constant for BRU_FILE_NAME

Updated the test cases to utilize a constant for the BRU_FILE_NAME regex pattern for better maintainability and readability.

* test(websocket): update BRU_FILE_NAME to use regex

Changed BRU_FILE_NAME from a string to a regex pattern for better matching.

* fix(ws-client): rename timeout to handshakeTimeout

Updated the WebSocket connection options to use 'handshakeTimeout' instead of 'timeout' for clarity.

* chore: cleanup

* fix(ShareCollection): update non-exportable request types handling

Refactor hasGrpcRequests to hasNonExportableRequestTypes,
returning an object with a flag and types of requests that
will not be exported.

* feat: inherit timeout from app prefs

* fix: faster queue

* feat: add WSRequestBodyMode component and language detection

- Introduced a new component for selecting request body modes (JSON, XML, TEXT).
- Implemented auto-detection of language for the request body content.
- Created a styled wrapper for improved UI presentation.

* feat: enhance WebSocket message handling with decoder support

- Added decoder field to WebSocket messages in various components.
- Updated prettify functionality to handle XML and JSON formats.
- Modified Redux state to include decoder information.
- Adjusted schema validation to accommodate decoder field.

* refactor: replace decoder with type in WebSocket message handling

* fix: use `body` directly

* chore: reset formatting

* chore: reformat

* chore: reformat

* chore: reformat

* chore: reformat

* chore: base format

* chore: fix lang constructs

* chore: fix message queue flush logic

Ensure that the flushQueue method checks for the existence of the message queue before processing.

Refactor WebSocket test fixtures for better readability by correcting indentation and structure.

* fix: typo

* chore: lint fixes

* chore: lint fixes

* chore: rediff utils

* chore: rediff utils

* chore: remove from CLI

* chore: rediff utils

* chore: rediff utils

* chore: rediff utils

* chore: rediff utils

* chore: fix formatting

* tests(websocket): add websocket persistence tests

* chore: format

* feat(eslint): add TypeScript support and update test file patterns

* fix: turn off single line jsx expressions

* revert lang `ws` removal

* chore: reformat

* feat: better subprotocol support and tests

* chore: reformat

* chore: reformat

* clean up ununsed components

* refactor: locators, tests, new request design

* chore: close app for each test to start afresh

* Revert "chore: close app for each test to start afresh"

This reverts commit 5c2e3bec81.

* refactor: simplify dropdown mode selection

* chore: remove unused changes

* refactor: simplify

* chore: simplify

* fix: loading pulse animation

* refactor: update lodash import syntax

* fix: comments and sanitisation

* refactor: rename BRU_FILE_NAME to BRU_REQ_NAME for consistency

Updated variable names across websocket tests to improve clarity and maintain consistency in naming conventions.

* fix: null check for the initialisation of websocket client

* fix: add poller to check for saved state

* fix: variable message time check for tests

* fix: force wait for elements

* fix: use nth locators instead of wait (draft attempt)

* chore: reformat

* fix: update beta preferences to include websocket support

* feat: GA

* feat: rename `connectionTimeout` to `timeout` and better form

* feat: update WebSocket IPC channel names to use 'renderer' prefix

* feat: add 'oauth2' to supported authentication modes

* chore: add default `json` type in ws

* test: add tests for bruToJson and jsonToBru parsers

- Implemented smoke tests for the bruToJson parser to validate message inference and settings.

* refactor: improve timeout handling in WebSocket client

---------

Co-authored-by: Siddharth Gelera <siddharthgelera@Siddharths-MacBook-Air.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2025-10-07 21:03:09 +05:30
Skewnart
c393dfe5d6 fix: fixing tests using locale on sizes 2025-10-07 16:33:20 +02:00
Pragadesh-45
cf17539a47 Refactor: Remove normalizeNewlines function and update tests to preserve newline types (#5697)
* refactor: remove `normalizeNewlines` function and update tests to preserve newline types
2025-10-07 18:43:19 +05:30
Anoop M D
608a9d1954 Merge pull request #5386 from pkolmann/bugfix-digest-auth
fix(digest-auth): fix Digest Auth when no QOP is set
2025-10-07 18:29:11 +05:30
Pragadesh-45
3a04d43ffe fix: lint 2025-10-07 18:05:46 +05:45
Pragadesh-45
5c9a391cc6 fix(digest-auth): handle multiple QOP values in Digest Auth 2025-10-07 17:39:38 +05:45
Pragadesh-45
df4b7c1337 feat(cli): ignore and skip invalid .bru file (#5711) 2025-10-07 15:20:45 +05:30
Pooja
db6a639c15 feat: add path based grouping for openapi (#5638)
* feat: add path based grouping for openapi
2025-10-07 13:32:11 +05:30
Siddharth Gelera (reaper)
85319769a5 feat: add Rosetta detection for Apple Silicon (#5717)
* feat: add Rosetta detection for Apple Silicon

* fix: update class attributes to className for React compatibility
2025-10-07 13:05:43 +05:30
Sanjai Kumar
8d2f087206 feat: enhance json environment file support in bruno-cli (#5660)
* feat: enhance json environment file support in bruno-cli

feat: add parseEnvironmentJson function to normalize environment JSON structure

lint fixes

feat: added tests for invalid JSON environment files in CLI and added missing constant defenition.

feat: improve JSON environment file handling and update tests

Trigger test

fix: update CLI command syntax for non-existent JSON environment file test

fix: correct CLI command syntax in test for non-existent JSON environment file

fix: update CLI command syntax in test for non-existent JSON environment file

fix: update test to use temporary path for non-existent JSON environment file

trying to fix the tests

fix tests

refactor: rename ERROR_INVALID_JSON to ERROR_INVALID_FILE and update related error handling in CLI commands and tests

fix: update parseEnvironmentJson to preserve secret flag

test: improved tests

* refactor: move parseEnvironmentJson function to utils/ environment.js file and update imports

* test: update tests
2025-10-07 12:49:22 +05:30
sanish chirayath
1cc3a6432a Feature: support import paths for gRPC (#5573)
* Enhance GrpcSettings component: update ui to improve user experience

Enhance GrpcSettings component: add import path management functionality

Refactor filesystem utility: remove duplicate isDirectory function and clean up code

Enhance GrpcQueryUrl component: add import path management and improve proto file selection functionality

Remove unused error message from GrpcQueryUrl component to streamline UI

Enhance GrpcSettings component: add editing functionality for proto files and import paths, improve UI for better user experience

Refactor GrpcSettings component: remove editing functionality for proto files and import paths, add replace import path feature, and update UI for improved feedback on file validity

Update GrpcQueryUrl component: change error message styling from red to yellow for improved visual feedback on invalid proto files and import paths

Refactor GrpcQueryUrl component: update styling for mode indicators and active tabs to use yellow color for improved visual consistency

Refactor ToggleSwitch component: add activeColor prop for customizable styling and update Checkbox background color logic to utilize activeColor

Update GrpcQueryUrl component: change dropdown and button styles to use yellow color for active states, enhancing visual consistency across the UI

Update GrpcSettings component: change error message styling from yellow to red for improved visibility and consistency in indicating invalid proto files and import paths

Refactor GrpcSettings component: remove hover background styles from table rows for a cleaner UI and maintain consistent button styling across actions

Refactor GrpcSettings component: remove Status column from the table and update error indication for invalid files with an alert icon for better visibility

Enhance Dropdown and GrpcQueryUrl components: add controlled visibility to Dropdown for improved interaction, and update loadGrpcMethodsFromProtoFile to accept collection for dynamic import paths, enhancing gRPC method loading functionality.

Refactor GrpcSettings component: streamline the display of proto files and import paths by consolidating empty state messages and enhancing error visibility with alert icons, while maintaining consistent table structure and button functionality.

Update GrpcQueryUrl component: simplify dependency array in useEffect and add conditional rendering for empty state messages regarding proto files and import paths, enhancing user feedback and clarity.

Refactor IconGrpc component: remove unused IconProto SVG definition to streamline the code and improve maintainability.

Refactor filesystem and network utility files: remove unnecessary blank lines to improve code readability and maintainability.

Update GrpcSettings and GrpcQueryUrl components: modify getBasename function to handle relative paths more effectively, and replace IconFile with IconFolder for improved visual consistency in the display of import paths.

Update Grpc components: enhance getBasename function to accept collection pathname for improved path resolution in GrpcSettings and GrpcQueryUrl, ensuring accurate display of proto file names.

Implement ProtobufSettings component: replace gRPC references with Protobuf, add functionality for managing proto files and import paths, and enhance UI with styled components for improved user experience.

Merge gRPC and Protobuf configurations for backward compatibility in CollectionSettings, ProtobufSettings, and GrpcQueryUrl components. Update state management and UI interactions to reflect the new structure, ensuring seamless transition from gRPC to Protobuf settings.

Add migration utility for gRPC to Protobuf configuration transition

Implement migration logic in collection-watcher to check and convert gRPC configurations to Protobuf format. Introduce a new utility for handling the migration process, ensuring backward compatibility and seamless updates to configuration files. This change enhances the application's ability to manage configuration transitions effectively.

Remove redundant migration logging and comments in collection-watcher.

Update loadGrpcMethodsFromProtoFile to use Protobuf configuration instead of gRPC. Adjust import path handling to reflect the new structure, ensuring compatibility with recent configuration transitions.

Enhance collection-watcher to send updated Protobuf configuration to the main process after migration. Remove redundant migration logic from the change function, streamlining the configuration handling process.

Add unit tests for gRPC to Protobuf migration utility

Introduce comprehensive tests for the migrateGrpcToProtobuf and needsMigration functions, covering various scenarios including config presence, merging, and handling of edge cases. This addition ensures the reliability of the migration process and validates the expected behavior of the utility functions.

Add initial tests for managing protofiles in Protobuf settings

Introduce a new test suite for managing protofiles, validating the visibility of protofiles and import paths in the Protobuf settings. The tests cover scenarios for loading methods from protofiles, handling invalid paths, and ensuring successful loading after providing necessary import paths. Additionally, a new collection configuration file is added to support the tests.

Reset gRPC methods state on loading errors in GrpcQueryUrl component. This ensures a clean state when encountering issues while loading methods from proto files, improving error handling and user feedback.

Enhance ProtobufSettings and GrpcQueryUrl components with data-test-ids for improved testing.

Refactor manage protofile tests to improve method loading verification. Update selectors for better specificity and ensure visibility of gRPC methods dropdown after selection.

Remove debug logging from getBasename function in path.js and refactor variable declaration in collection-watcher.js for improved clarity.

Refactor GrpcQueryUrl component to enhance dropdown item styling and improve method selection feedback. Update class names for better visual transitions and ensure consistent appearance across selected and hover states.

Refactor GrpcQueryUrl component by removing the GrpcurlModal implementation and its associated logic. This change streamlines the component and prepares for future enhancements.

Refactor GrpcQueryUrl component by introducing TabNavigation, ProtoFilesTab, and ImportPathsTab for improved organization and readability. This change enhances the user interface by streamlining tab management and separating concerns within the component.

Remove visibility check for loaded gRPC methods in manage protofile tests to streamline method selection process. Update selectors for improved specificity.

Refactor collection-watcher.js to remove gRPC migration logic and update configuration handling. Delete grpc-to-protobuf migration utility and associated tests to streamline codebase and eliminate redundancy.

Refactor GrpcQueryUrl component to rename gRPC-related functions and improve button click handling. Update dropdown item styling for consistency and enhance the visibility of proto files and import paths in the user interface. Add new test data for collection management and update paths in user data preferences.

Refactor path utility functions by removing getDirPath and updating exports in path.js. Adjust imports in Protobuf component to reflect these changes. Clean up filesystem.js by removing unused fs and fsPromises imports.

Refactor ProtobufSettings and GrpcQueryUrl components: improve code readability by standardizing arrow function syntax, enhancing UI feedback for proto files and import paths, and ensuring consistent styling across components.

Update manage protofile tests: change selector for collection path name to improve test specificity and ensure accurate visibility of protofiles in the Protobuf settings.

Refactor path utility functions and update component logic: modify getRelativePath and getBasename functions to accept parameters in a consistent order, enhancing path resolution across ProtobufSettings and GrpcQueryUrl components. Simplify filesystem utility functions by removing error handling for IPC calls, improving code clarity. Add comprehensive unit tests for path utilities to ensure reliability and correctness across different platforms.

fix: lint

feat: Add jsdocs to getAbsoluteFilePath utility function

refactor: Enhance GrpcQueryUrl and related components with styled wrappers for improved UI consistency

- Removed the "BETA" label from GrpcurlModal for a cleaner interface.
- Introduced StyledWrapper components for ImportPathsTab and ProtoFilesTab to encapsulate styling and improve readability.
- Updated TabNavigation to utilize StyledWrapper, enhancing the overall layout and design.
- Added new styles in the dark and light themes to support the updated UI elements, ensuring a cohesive look across components.

refactor: Enhance GrpcQueryUrl and related components with styled wrappers for improved UI consistency

- Removed the "BETA" label from GrpcurlModal for a cleaner interface.
- Introduced StyledWrapper components for ImportPathsTab and ProtoFilesTab to encapsulate styling and improve readability.
- Updated TabNavigation to utilize StyledWrapper, enhancing the overall layout and design.
- Added new styles in the dark and light themes to support the updated UI elements, ensuring a cohesive look across components.

refactor

feat: Enhance error handling and user feedback in GrpcQueryUrl and useProtoFileManagement

feat: Refactor GrpcQueryUrl component and introduce MethodDropdown and ProtoFileDropdown for improved user experience

- Removed unused imports and state variables to streamline the GrpcQueryUrl component.
- Introduced MethodDropdown for better organization of gRPC methods, enhancing selection and display.
- Added ProtoFileDropdown to manage proto file selection and import paths, improving user interaction.
- Updated UI elements for consistency and clarity, including dropdowns and method selection feedback.
- Enhanced error handling and user feedback mechanisms throughout the component.

refactor: rm comments

fix: linting

refactor: streamline proto file and import path management in useProtoFileManagement and useReflectionManagement hooks

refactor: use hook for protofile management within collection settings

fix: lint

fix: e2e tests

refactor: use getByTestId within playwright tests

refactor: enhance path utilities for cross-platform compatibility

* fix: lint

* test: add cleanup step to manage protofile tests for improved isolation
2025-10-07 12:47:16 +05:30
Pooja
28907a203f fix: Show active global environment in config modal (#5698)
* fix: Show active global environment in config modal
* add: delayShow prop in tooltip
2025-10-07 12:30:53 +05:30
Philipp Kolmann
6204e90e9c fix(digest-auth): fix Digest Auth when no QOP is set
(working on usebruno/bruno#5378)
2025-10-07 11:32:38 +05:45
sanish chirayath
1d0ba135ff Enable gRPC (Beta to GA) (#5687)
* refactor: remove gRPC feature toggle from CollectionSettings and Presets components

* fix: lint error

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2025-10-06 23:16:19 +05:30
Siddharth Gelera (reaper)
3c72975314 fix: removeMenu on about window (#5712) 2025-10-06 15:30:14 +05:30
Andrii Oriekhov
3fa9fea6a4 use request directory as the destination for saving response (#5699)
* use request directory as the destination for saving response
* use request directory as the destination for saving response
2025-10-04 02:33:28 +05:30
Anoop M D
239f1dc9f5 Merge pull request #5690 from james-ha-bruno/feat/add-get-tags-for-requests
adding req getTags methods
2025-10-04 02:04:11 +05:30
James Ha
28e37d8f6f feat(#5689): req.getTags() api 2025-10-04 01:45:33 +05:30
Anoop M D
8b28070695 Merge pull request #5666 from usebruno/feat/tab-reordering-internal
feat: extended additions for tab reordering (#5413)
2025-10-02 08:44:28 +05:30
Bijin A B
4ae55b8f1a fix: update interpolate-request-url.spec.ts test flow (#5682) 2025-10-01 13:45:51 +05:30
Sanjai Kumar
8bad0262c6 feat: Enhance EnvironmentVariables component with read-only support for non-string values (#5616)
* feat: Enhance EnvironmentVariables component with read-only support for non-string values

* feat: minor refactor and cleanup worker app state

* fix: playwright test flow

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-10-01 02:36:45 +05:30
Sanjai Kumar
c7029d1cda fix: improve file upload handling in prepare-request to use streaming (#5637)
* fix: improve file upload handling in prepare-request to use streaming
* feat: add unit tests

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-09-30 22:47:09 +05:30
Sid
bb44d9e193 feat: add draggable tabs component (#5669) 2025-09-30 14:27:25 +05:30
Jayakrishnan C N
14966f6e6c feat: import multiple collections from a parent folder (#5431)
* feat: import multiple collections from a parent folder
* feat: open collections in parallel, revert plural labels, and update playwright tests

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-09-30 13:27:20 +05:30
Siddharth Gelera (reaper)
56f0741121 chore: extract ts support for aslant from feat/websocket-engine (#5664)
* chore: extract ts support
2025-09-30 11:23:47 +05:30
Roland Schaer
b1840d189d feat: make tabs reorderable (#5413) 2025-09-30 08:59:25 +05:30
naman-bruno
aacb1e0b8e Merge pull request #5635 from naman-bruno/feat/performance-monitor
add: system monitor
2025-09-29 19:37:56 +05:30
Anoop M D
fa0f3b3b7b Merge pull request #5661 from barelyhuman/fix/eslint-comma-arrow
fix: update stylistic rules in ESLint configuration
2025-09-29 15:46:27 +05:30
Siddharth Gelera
2a00add966 fix: update stylistic rules in ESLint configuration
- Added 'comma-dangle' rule to disallow trailing commas.
- Changed 'arrow-parens' rule to require parentheses for arrow functions.
2025-09-29 14:54:56 +05:30
Mauricio Sanabria
41e0615f77 Feature: Add collapse full collection feature (#4492)
* Add collapse collection feature
---------
Co-authored-by: Anoop M D <anoop@usebruno.com>
2025-09-29 13:07:10 +05:30
Rudra Patel
191a997b05 feat: Add button to copy environment variable from popover (#5416)
* feat: Add copy button to environment variable hover

* feat: Add success state

* feat: Clean up code

* feat: Add DOM test for popover and copy button functionality

* feat: Add more robust tests

* chore: reformat

---------

Co-authored-by: Siddharth Gelera <ahoy@barelyhuman.dev>
2025-09-29 13:00:42 +05:30
Pragadesh-45
123fe7d542 Merge pull request #5557 from Pragadesh-45/feat/default-location
feat: default location for collections
2025-09-25 22:53:08 +05:30
Pooja
187f5ca011 feat: add support for file body mode in bruno-cli (#5427)
* feat: add support for `file` body mode in `bruno-cli` Fixes #4336
* fix: Correct await/async on file reading.
* fix: update test and fix lint errors

---------

Co-authored-by: William Floyd <william.floyd@modopayments.com>
Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-09-25 17:03:48 +05:30
Pooja
e1b4043ca5 fix: openapi request import (#5586)
* fix: openapi request import
* fix: js sandbox mode selector doesn't show up while opening new collections in playwright tests

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-09-25 13:06:02 +05:30
sanish chirayath
9c9cfdf0b2 fix: update preferences saving method in preferences utility (#5617)
* fix: update preferences saving method in preferences utility

* fix: make markAsLaunched asynchronous and improve error handling in onboarding process

* fix: lint errors

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-09-24 19:28:30 +05:30
Anoop M D
daf6a6d5d6 Merge pull request #5629 from barelyhuman/ci/eslint-run-on-main
ci: fallback to `main` as base ref for ESLint
2025-09-24 18:11:14 +05:30
Siddharth Gelera
95a2ca9558 ci: fallback to main 2025-09-24 17:28:31 +05:30
Anoop M D
f359303927 Merge pull request #5615 from usebruno/fix/odata-style-pathparams
Support for Odata style path params
2025-09-24 15:23:07 +05:30
Pragadesh-45
65f52961c5 Merge pull request #5613 from Pragadesh-45/main 2025-09-24 13:59:45 +05:30
Pooja
2a3db96c9b fix: Add null safety checks in GlobalSearchModal (#5625)
* fix: Add null safety checks in GlobalSearchModal
2025-09-24 13:57:58 +05:30
Bijin A B
a1a7c9a136 remove the custom test timeout as default would be enough 2025-09-24 13:56:41 +05:30
Siddharth Gelera (reaper)
c15d47c0dc chore: base format (#5624) 2025-09-24 13:00:54 +05:30
Pragadesh-45
e4f8945e89 fix: add Linux support for xdg-portal version in Electron app (#5618) 2025-09-24 01:53:41 +05:30
John Vester
e6c136d2bb Merge pull request #5582 from johnjvester/5579_correct_spelling
5579 - correct spelling error and introduce constant to avoid duplication
2025-09-24 00:40:58 +05:30
sid-bruno
6f8c543ee3 tests: additional tests for path params and odata (#5610)
* Support for Odata style path params (#5048)

* Support for Odata style path params

* Remove test statement

* Update packages/bruno-app/src/utils/url/index.spec.js

Add more testcases

Co-authored-by: sid-bruno <siddharth@usebruno.com>

* Performance improvements

* Add testcases for odata style url params

---------

Co-authored-by: sid-bruno <siddharth@usebruno.com>

* tests: additional tests for odata and path params

tests(electron): add in odata smoke for interpolation

chore: code format

chore: ESLint atomic diff based formatting (#5592)

* chore: atomic diff based formatting

chore(format): fix formatting

tests(playwright): interpolation tests

Support for Odata style path params (#5048)

* Support for Odata style path params

* Remove test statement

* Update packages/bruno-app/src/utils/url/index.spec.js

Add more testcases

Co-authored-by: sid-bruno <siddharth@usebruno.com>

* Performance improvements

* Add testcases for odata style url params

---------

Co-authored-by: sid-bruno <siddharth@usebruno.com>

---------

Co-authored-by: Anton <anton@trugen.net>
Co-authored-by: Siddharth Gelera <ahoy@barelyhuman.dev>
2025-09-23 15:55:04 +05:30
Anton
40b44de294 Support for Odata style path params (#5048)
* Support for Odata style path params

* Remove test statement

* Update packages/bruno-app/src/utils/url/index.spec.js

Add more testcases

Co-authored-by: sid-bruno <siddharth@usebruno.com>

* Performance improvements

* Add testcases for odata style url params

---------

Co-authored-by: sid-bruno <siddharth@usebruno.com>
2025-09-23 13:50:51 +05:30
Siddharth Gelera (reaper)
f24e1e78fe chore: ESLint atomic diff based formatting (#5592)
* chore: atomic diff based formatting
2025-09-23 13:36:34 +05:30
Pooja
87d8c5ccb7 fix: env name overflow (#5598) 2025-09-19 19:36:37 +05:30
Pragadesh-45
17d5629627 refactor: Replace SingleLineEditor with MultiLineEditor in EnvironmentVariables components and add masking functionality (#5576)
* refactor: Replace SingleLineEditor with MultiLineEditor in EnvironmentVariables components and add masking functionality
- Adjusted related components to support the new editor and its features, including toggling visibility for secret values.

---------

Co-authored-by: lohit-bruno <lohit@usebruno.com>
2025-09-18 22:31:09 +05:30
Sanjai Kumar
4321846dbd feat: Add end-to-end tests for collection run reports (#5562)
* feat: Add end-to-end tests for collection run reports
2025-09-18 15:20:28 +05:30
Pooja
f3d4ac84d8 fix: environment list scroll (#5585) 2025-09-18 11:23:49 +05:30
Sanjai Kumar
de52ceea48 refactor: moved sample collection JSON to resources folder inside bruno-electron and updated electron-builder-config (#5581) 2025-09-17 20:45:33 +05:30
Pooja
65e69e77b3 revamp: collection and global env selector dropdown (#5542)
* revamp: collection and global env selector dropdown

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Pragadesh-45 <54320162+Pragadesh-45@users.noreply.github.com>
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
Co-authored-by: sanish-bruno <sanish@usebruno.com>
Co-authored-by: bernborgess <bernborgesse@outlook.com>
Co-authored-by: lohit <lohit@usebruno.com>
Co-authored-by: Its-Treason <39559178+Its-treason@users.noreply.github.com>
Co-authored-by: jayakrishnancn <jayakrishnancn@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-17 19:35:00 +05:30
Sanjai Kumar
fb2ca8937e feat: add environment variable DISABLE_SAMPLE_COLLECTION_IMPORT to control sample collection import behavior (#5567)
* feat: add environment variable DISABLE_SAMPLE_COLLECTION_IMPORT to control sample collection import behavior
2025-09-17 13:57:10 +05:30
Anoop M D
e2da072e8b Merge pull request #5566 from lohit-bruno/crypto_safe_mode_support
fix crypto-js in safe mode
2025-09-16 16:11:05 +05:30
lohit-bruno
90492d6e79 fix crypto-js in safe mode 2025-09-16 15:39:06 +05:30
Sanjai Kumar
5393e3b496 feat: Add default sample collection on first app launch (#5536)
* feat: Add Default Sample Collection On First Launch
* feat(initial-load): add  attribute for app readiness

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
2025-09-15 15:00:39 +05:30
lohit
9fc885839f ca certs function updates (#5555) 2025-09-12 21:49:49 +05:30
Pragadesh-45
dbfbde43cf refactor: Replace MultiLineEditor with SingleLineEditor in EnvironmentVariables components (#5554) 2025-09-12 21:49:33 +05:30
lohit
1aa4e27ab5 use node:tls library for ca certs (#5552) 2025-09-12 20:29:28 +05:30
Siddharth Gelera (reaper)
2b6da56c3c fix(electron): avoid double encoding urls params. Fixes #5380. (#5507)
* fix(common): avoid double encoding urls params
2025-09-12 19:08:53 +05:30
lohit
c08827b0c0 ca certs updates and fixes (#5549) 2025-09-12 16:03:27 +05:30
Anoop M D
841d977725 Merge pull request #5547 from lohit-bruno/ca_certs_fixes_system_ca
use system-ca library for ca certs
2025-09-12 01:11:25 +05:30
Anoop M D
56629663dc Remove flaky header size test from getSize
Removed test for header size from getSize tests.
2025-09-12 01:05:24 +05:30
lohit-bruno
27cbb194bf use system-ca library for ca certs 2025-09-12 00:33:22 +05:30
Anoop M D
cfec4a9e1b Merge pull request #5531 from helloanoop/chore/update-digest-tests
Update digest authentication test cases with new URLs and credentials
2025-09-08 22:58:57 +05:30
Anoop M D
a7f6d669af Update digest authentication test cases with new URLs and credentials 2025-09-08 22:50:11 +05:30
dependabot[bot]
e57162b79a build(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 17:10:22 +00:00
Anoop M D
03abbc585f Remove body size test from getSize tests 2025-09-08 22:36:22 +05:30
Anoop M D
be730a8c4f Merge pull request #5529 from usebruno/dependabot/github_actions/actions/setup-node-5
chore(deps): bump actions/setup-node from 4 to 5
2025-09-08 22:29:25 +05:30
dependabot[bot]
194d904284 chore(deps): bump actions/setup-node from 4 to 5
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 16:21:53 +00:00
Anoop M D
86b3c65dcd Merge pull request #5525 from sanish-bruno/feat/moving-requests-cross-collection
Feature: moving requests cross collection
2025-09-08 20:05:04 +05:30
sanish chirayath
c9fe9813db Merge pull request #5526 from sanish-bruno/fix/tags-removed-while-moving-request
Fix: tags removed while moving request
2025-09-08 20:03:36 +05:30
sanish-bruno
70d65d87c5 move: test cases to new folder 2025-09-08 16:32:13 +05:30
jayakrishnancn
0bce203851 feat: Move requests between collections #3320
test: update e2e test case for moving request from one collection to another

test: updated the tests

test: added more test cases

test: e2e test updated

test: fixed test case

test: fixed test cause for folder

fix: add grpc-request to clone-folder

fix: removed handleCrossCollectionItemMove method

test: updated e2e test cases

fix: removed cross-collection gurard statement

format: revert format

fix: UX changes for collection drag and drop
2025-09-08 16:29:46 +05:30
lohit
5b716cbe60 node vm fixes (#5519) 2025-09-08 06:39:18 +05:30
lohit
a6b0b6c117 node vm support (#5518)
Co-authored-by: Its-Treason <39559178+Its-treason@users.noreply.github.com>
2025-09-08 06:09:25 +05:30
lohit
3c656270b3 ca certs fixes and tests (#5429)
Co-authored-by: Anoop M D <anoop.md1421@gmail.com>
2025-09-07 23:06:44 +05:30
Anoop M D
1bc7a1f655 Modify body size test to check for > 1MB 2025-09-07 21:49:11 +05:30
Anoop M D
5a10322608 Merge pull request #5490 from bernborgess/bugfix/global-shortcut-unfocused
Bugfix/global shortcut unfocused
2025-09-07 21:24:29 +05:30
Anoop M D
2864ddaa72 Merge pull request #5262 from sanish-bruno/add/playwright-testing-guide
Add Playwright testing guide for Bruno application
2025-09-07 04:59:23 +05:30
Anoop M D
c2f3d8e7da Merge pull request #5238 from helloanoop/feat/loc-count-script
feat: script to calculate locs of repo
2025-09-07 04:48:52 +05:30
Anoop M D
1fd61f0601 Merge pull request #5517 from helloanoop/refactor-tests
Refactor tests
2025-09-07 03:23:01 +05:30
Anoop M D
033c5cc0f7 Refactor tests 2025-09-07 03:05:11 +05:30
Pragadesh-45
db35e7059c Merge pull request #5438 from Pragadesh-45/feat/multiline-values-for-env-vars
Feat/ Add Multiline Support for Enviroment Variables
2025-09-06 17:17:51 +05:30
bernborgess
cd80332de9 fix: Make globalShortcut only active when app is focused 2025-09-03 14:02:01 -03:00
Anoop M D
1902329226 Merge pull request #5491 from naman-bruno/add/vscode-image
add: vscode image
2025-09-03 22:09:25 +05:30
naman-bruno
b25569d29a add: vscode image 2025-09-03 22:08:24 +05:30
Pooja
de4674dcc4 add: playwright test for import collection modal (#5487)
* add: playwright test for import collection modal
2025-09-03 19:14:08 +05:30
naman-bruno
457a2f83e7 fix: Bruno GUI hangs on 308 redirect (#5445)
* fix: 308 redirect
2025-09-03 17:52:12 +05:30
Pragadesh-45
ae3d5a5515 fix(apt): ensure Bruno repo key is world-readable on Debian 12+ (#5474)
- Added `chmod 644 /etc/apt/keyrings/bruno.gpg` so `_apt` user can read the key
- Keeps key in binary format with `gpg --dearmor`
- Prevents NO_PUBKEY errors and repository being ignored on Debian 12+
2025-09-03 17:43:58 +05:30
Bijin A B
3b74e0da86 fix(curl-parser): curl commands with url without protocol (#5453) 2025-09-03 16:05:19 +05:30
Pooja
985b5ed20c add: global search modal (#5400)
* add: global search modal
2025-09-03 15:32:18 +05:30
Anoop M D
188a2e63e3 Add HttpMethodSelector component tests (#5481) 2025-09-03 10:33:54 +05:30
Anoop M D
01839c8e5f Merge pull request #5435 from notKvS/theme-fix
fix: graphQL documentation theme
2025-09-03 01:18:11 +05:30
Sanjai Kumar
648581ded5 feat: custom HTTP method (#4841) 2025-09-02 23:09:48 +05:30
sreelakshmi-bruno
bf38cc0f51 adding metadata to report (#5360) 2025-09-02 15:11:23 +05:30
sanish chirayath
abddc98767 feat: add WSSE authentication support to gRPC requests (#5455)
* feat: add WSSE authentication support to gRPC requests

- Introduced WSSE authentication mode in GrpcAuth component.
- Updated supported authentication modes to include WSSE.
- Refactored gRPC event handlers to streamline authentication header setting.
- Added notes regarding limitations of complex auth modes in gRPC.

* fix: update authentication header retrieval in setAuthHeaders function

- Refactored the setAuthHeaders function to correctly retrieve WSSE and OAuth2 refresh token URLs from the request object instead of the collectionAuth object.
- Added comprehensive tests for various authentication modes, ensuring proper inheritance and request-level overrides for AWS v4, basic, bearer, digest, NTLM, WSSE, API key, and OAuth2 authentication methods.

* chore: remove outdated comments on gRPC authentication limitations
2025-09-01 19:19:14 +05:30
Pooja
3fa05d32cb fix: openapi auth import in bruno (#5354)
* fix: openapi auth import in bruno
2025-09-01 15:15:30 +05:30
Pragadesh-45
eb0accdf21 Update Bruno's Age 🎉 (#5328)
* feat: Update Bruno's age calculation in tests and specs to use a dynamic function instead of a static value.
2025-09-01 12:13:27 +05:30
Anoop M D
6f57633572 Merge pull request #5466 from helloanoop/footer-playwright-tests 2025-09-01 10:47:14 +05:30
Anoop M D
e7c33f7eef Add playwright tests for Notifications Modal and Sidebar Toggle functionality 2025-08-30 20:24:52 +05:30
ganesh
1620c24557 update the grpc tagline (#5449)
* updated grpc tagline

* add share feeback hyperlink
2025-08-30 13:05:30 +05:30
Bijin A B
bd9d2eabe1 fix: upgrade @faker-js/faker from 8.4.0 to 8.4.1 (#5347)
Snyk has created this PR to upgrade @faker-js/faker from 8.4.0 to 8.4.1.

See this package in npm:
@faker-js/faker

See this project in Snyk:
https://app.snyk.io/org/bruno-8a2f42NP3GxXppW5covrLX/project/dfa3b6f8-996e-40e0-85d9-2df8ec62bdbd?utm_source=github&utm_medium=referral&page=upgrade-pr

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2025-08-30 10:16:23 +05:30
Anoop M D
990bbdb813 Merge pull request #5458 from Pragadesh-45/fix/keybinding-issues-with-global-shortcut
Refactor: Remove use of `globalShortcut` for minimize/hide to avoid hijacking system shortcuts
2025-08-30 02:42:36 +05:30
Anoop M D
00636a5a31 Merge pull request #5459 from josbiz/fix/suggestion-box-behind-modal
Add z-index to CodeMirror hint box
2025-08-30 02:27:46 +05:30
Jose Bolivar Ibz
c526eacd6b Add z-index to CodeMirror hint box 2025-08-29 20:32:14 +00:00
Anoop M D
9a2836129f Merge pull request #3178 from pietrygamat/bugfix/571
fix: unable to set request bodies with colon characters in their names
2025-08-30 01:57:54 +05:30
Anoop M D
b8d67d9232 Merge branch 'main' into bugfix/571 2025-08-30 01:52:46 +05:30
Anoop M D
bcf4673a64 chore: Update Bruno grammar and tests to support keys with spaces, braces, and nested escaped quotes in headers and query parameters 2025-08-30 01:44:33 +05:30
Pragadesh-45
6c52c07494 refactor: remove use of globalShortcut for minimize/hide to avoid hijacking system shortcuts 2025-08-30 00:42:32 +05:45
Jose Bolivar Ibz
1de9203dd5 Merge branch 'main' into fix--dot-on-proxy-options-when-unused 2025-08-29 08:45:44 -07:00
Pooja
de48c93e8d fix: store redirect cookies under initial request domain (#5387) 2025-08-29 21:05:18 +05:30
Pragadesh-45
ba56e87375 Feat: Collapsable Sidebar (#5302) 2025-08-29 21:03:46 +05:30
sanish chirayath
cb7f61ee4b Implement legacy Postman global API transformations (#5403) 2025-08-29 21:01:06 +05:30
Pragadesh-45
6bcb850b6e fix: resolve URL and method handling in digest auth interceptor (#5317) 2025-08-29 21:00:17 +05:30
ganesh
dc56c00309 changed example to cjs syntax (#4526)
* changed example to cjs syntax
2025-08-29 14:46:25 +05:30
Bijin A B
1220a5f159 Merge pull request #5253 from jokj624/bugfix/running-test-filtering
fix: incorrectly counts running/in-progress requests
2025-08-29 14:34:26 +05:30
Bijin A B
3046327fa7 Merge pull request #5139 from ganesh-bruno/fix/rename-path-value
rename query table value from path to value
2025-08-29 14:17:30 +05:30
Anoop M D
c1c617bfeb Merge pull request #5436 from ganesh-bruno/fix/bruno-readme
change landing page of Bruno
2025-08-28 18:14:47 +05:30
lohit
6632407a34 include oauth2 additional parameters in bruno collection exports (#5422) 2025-08-28 18:10:08 +05:30
Sanjai Kumar
447b3046b3 fix: environment persistence and UI (#5404) 2025-08-28 18:09:37 +05:30
Anoop M D
2666e7fee0 Merge pull request #5412 from jbraconig/fix/debian-trixie-install
Update: readme.md installation instructions via Apt (#5411)
2025-08-28 17:57:27 +05:30
ganesh-bruno
f9ca0e2f5a change landing page of Bruno 2025-08-28 14:59:39 +05:30
notKvS
5dd90e1386 fix: graphQL documentation theme 2025-08-27 20:47:16 +05:30
Bijin A B
5e9cec38f0 Merge pull request #5385 from naman-bruno/bugfix/large-response
fix: Large response crash bruno
2025-08-26 20:10:34 +05:30
lohit
ed1a072ba1 chore: eslint updates and fixes (#5402) 2025-08-26 18:49:50 +05:30
Pooja
5f938d77b4 feat: new import modal (#5050) 2025-08-26 18:32:02 +05:30
lohit
f5b4dbd1a1 electron builder updates (#5425) 2025-08-26 15:13:56 +05:30
Bijin A B
8c72a6094b Update contributing.md (#5407) 2025-08-26 14:44:50 +05:30
Coel Aspey
325d03b92f feat: Persist response body scroll position across tabs (#3902) 2025-08-25 17:21:34 +05:30
tlaloc911
54c41c861e Show request body in devtools (#5337) 2025-08-25 16:46:26 +05:30
Martin Braconi
af894b5bbb Update: readme.md installation instructions via Apt (#5411) 2025-08-23 14:12:50 -05:00
naman-bruno
e74c78ea8b fix: large response 2025-08-21 01:59:39 +05:30
sanish-bruno
da2f2519ec feat: add Playwright testing guide for Bruno application 2025-08-05 17:17:38 +05:30
jokj624
8bd2216bf0 fix: check running status in runner results 2025-08-04 19:26:06 +09:00
jokj624
4cfc28cd73 fix: incorrectly counts running/in-progress requests 2025-08-04 18:44:23 +09:00
Anoop M D
7efaa427ca feat: script to calculate locs of repo 2025-08-01 13:50:13 +05:30
Jose Bolivar Ibz
cffa37ed50 fix: show dot on used preset setting 2025-07-22 12:17:19 -07:00
Jose Bolivar Ibz
bcf61f507a Update index.js
Variable change from let to const
2025-07-22 12:10:14 -07:00
Jose Bolivar Ibz
325b573da9 fix: dot on unused proxy settings 2025-07-22 11:51:02 -07:00
ganesh-bruno
b3a0234ec3 rename query table value from path to value 2025-07-18 13:33:29 +05:30
apealpha
4b5c7dcca6 fix(request): prettify JSON with variables 2025-07-16 00:00:26 +09:00
anusree-bruno
d2888daa88 add process.env variable support to GraphQL introspection 2025-06-30 13:12:35 +05:30
ZieglerZhu
ec9d63219f docs(readme): update readme_cn.md
- Added introduction of commercial version
- Added Table of Contents
- Updated installation guide via package managers
- Updated important links
- Adjusted document structure, optimized title hierarchy
2025-05-22 23:38:33 +08:00
pooja-bruno
9173ffbdee improve: use common getTreePathFromCollectionToItem function 2025-05-19 12:42:10 +05:30
anusree-bruno
5f112a318d added option to revert changes 2025-04-15 00:11:41 +05:30
Mateusz Pietryga
8c6ce2e084 fix: Allow quoting some special characters in names of multipart request body parts and headers in bru files
Fixes #571
2025-01-20 23:18:36 +01:00
Mateusz Pietryga
b02f6b61ee fix: Adjust Bruno grammar to allow quoting some special characters in names query parameters
Automatically quote query parameter keys if they contain characters sensitive for bru syntax: ':', '"', '{', '}' or ' '.
Quoted keys stored in .bru files now support escaping, so it is possible to store keys that themselves contain '"' character.

Fixes #3037
Fixes #2810
Fixes #2878
2025-01-20 23:10:50 +01:00
622 changed files with 36767 additions and 4675 deletions

View File

@@ -0,0 +1,26 @@
name: 'Setup Node Dependencies'
description: 'Install Node.js and npm dependencies'
runs:
using: 'composite'
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: v22.17.0
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install node dependencies
shell: bash
run: npm ci --legacy-peer-deps
- name: Build libraries
shell: bash
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
npm run build:bruno-filestore

View File

@@ -0,0 +1,36 @@
name: 'Run Basic SSL CLI Tests - Linux'
description: 'Run basic SSL CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to basic SSL test collection directory
cd tests/ssl/basic-ssl/collections/badssl
echo "basic ssl success"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "with default/system ca certs"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
# navigate to self-signed SSL test collection directory
cd ../self-signed-badssl
echo "self-signed ssl with validation disabled"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
echo "self-signed ssl with default/system ca certs"
echo "request will error"
# should fail
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,33 @@
name: 'Run Custom CA Certs CLI Tests - Linux'
description: 'Run custom CA certs CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to CA certificates test collection directory
cd tests/ssl/custom-ca-certs/collection
echo "custom valid ca cert"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "custom valid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert"
echo "request will error"
# should fail
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,19 @@
name: 'Run SSL E2E Tests - Linux'
description: 'Run SSL E2E tests on Linux'
runs:
using: 'composite'
steps:
- name: Run E2E tests
shell: bash
run: |
set -euo pipefail
xvfb-run npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-linux
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,26 @@
name: 'Setup CA Certificates - Linux'
description: 'Setup CA certificates and start test server for custom CA certs tests on Linux'
runs:
using: 'composite'
steps:
- name: Setup CA certificates
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "running certificate setup"
node scripts/generate-certs.js
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "starting server in background"
node index.js &
echo "server started with PID: $!"

View File

@@ -0,0 +1,15 @@
name: 'Setup Custom CA Certs Feature Dependencies - Linux'
description: 'Setup feature-specific dependencies for custom CA certs tests on Linux'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for custom CA certs
shell: bash
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb libxml2-utils
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox

View File

@@ -0,0 +1,36 @@
name: 'Run Basic SSL CLI Tests - macOS'
description: 'Run basic SSL CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to basic SSL test collection directory
cd tests/ssl/basic-ssl/collections/badssl
echo "basic ssl success"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "with default/system ca certs"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
# navigate to self-signed SSL test collection directory
cd ../self-signed-badssl
echo "self-signed ssl with validation disabled"
# should pass
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --insecure --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit3.xml | grep -q "^1$" || exit 1
echo "self-signed ssl with default/system ca certs"
echo "request will error"
# should fail
node ../../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,33 @@
name: 'Run Custom CA Certs CLI Tests - macOS'
description: 'Run custom CA certs CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: bash
run: |
set -euo pipefail
# navigate to CA certificates test collection directory
cd tests/ssl/custom-ca-certs/collection
echo "custom valid ca cert"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit1.xml --cacert ../server/certs/ca-cert.pem --ignore-truststore --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit1.xml | grep -q "^1$" || exit 1
echo "custom valid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit2.xml --cacert ../server/certs/ca-cert.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit2.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert"
echo "request will error"
# should fail
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit3.xml --cacert ../server/certs/ca-key.pem --ignore-truststore --format junit 2>/dev/null || true
xmllint --xpath 'count(//testsuite[@errors="1"])' junit3.xml | grep -q "^1$" || exit 1
echo "custom invalid ca cert with defaults"
# should pass
node ../../../../packages/bruno-cli/bin/bru.js run ./request.bru --output junit4.xml --cacert ../server/certs/ca-key.pem --format junit
xmllint --xpath 'count(//testsuite[@errors="0"])' junit4.xml | grep -q "^1$" || exit 1

View File

@@ -0,0 +1,17 @@
name: 'Run SSL E2E Tests - macOS'
description: 'Run SSL E2E tests on macOS'
runs:
using: 'composite'
steps:
- name: Run E2E tests
shell: bash
run: |
npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-macos
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,26 @@
name: 'Setup CA Certificates - macOS'
description: 'Setup CA certificates and start test server for custom CA certs tests on macOS'
runs:
using: 'composite'
steps:
- name: Setup CA certificates
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "running certificate setup"
node scripts/generate-certs.js
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd tests/ssl/custom-ca-certs/server
echo "starting server in background"
node index.js &
echo "server started with PID: $!"

View File

@@ -0,0 +1,9 @@
name: 'Setup Custom CA Certs Feature Dependencies - macOS'
description: 'Setup feature-specific dependencies for custom CA certs tests on macOS'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for custom CA certs
shell: bash
run: |
brew install libxml2

View File

@@ -0,0 +1,50 @@
name: 'Run Basic SSL CLI Tests - Windows'
description: 'Run basic SSL CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# navigate to basic SSL test collection directory
Set-Location tests\ssl\basic-ssl\collections\badssl
Write-Host "basic ssl success"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml1 = Get-Content junit1.xml
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount1 -ne 1) { exit 1 }
Write-Host "with default/system ca certs"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml2 = Get-Content junit2.xml
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount2 -ne 1) { exit 1 }
# navigate to self-signed SSL test collection directory
Set-Location ..\self-signed-badssl
Write-Host "self-signed ssl with validation disabled"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --insecure --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml3 = Get-Content junit3.xml
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount3 -ne 1) { exit 1 }
Write-Host "self-signed ssl with default/system ca certs"
Write-Host "request will error"
# should fail
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
# Ignore the exit code - we expect this to fail
[xml]$xml4 = Get-Content junit4.xml
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
if ($errorCount4 -ne 1) { exit 1 }

View File

@@ -0,0 +1,47 @@
name: 'Run Custom CA Certs CLI Tests - Windows'
description: 'Run custom CA certs CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# navigate to CA certificates test collection directory
Set-Location tests\ssl\custom-ca-certs\collection
Write-Host "custom valid ca cert"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit1.xml --cacert ..\server\certs\ca-cert.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml1 = Get-Content junit1.xml
$testsuites1 = if ($xml1.testsuites) { $xml1.testsuites.testsuite } else { $xml1.testsuite }
$errorCount1 = ($testsuites1 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount1 -ne 1) { exit 1 }
Write-Host "custom valid ca cert with defaults"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit2.xml --cacert ..\server\certs\ca-cert.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml2 = Get-Content junit2.xml
$testsuites2 = if ($xml2.testsuites) { $xml2.testsuites.testsuite } else { $xml2.testsuite }
$errorCount2 = ($testsuites2 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount2 -ne 1) { exit 1 }
Write-Host "custom invalid ca cert"
Write-Host "request will error"
# should fail
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit3.xml --cacert ..\server\certs\ca-key.pem --ignore-truststore --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
# Ignore the exit code - we expect this to fail
[xml]$xml3 = Get-Content junit3.xml
$testsuites3 = if ($xml3.testsuites) { $xml3.testsuites.testsuite } else { $xml3.testsuite }
$errorCount3 = ($testsuites3 | Where-Object { $_.errors -eq "1" } | Measure-Object).Count
if ($errorCount3 -ne 1) { exit 1 }
Write-Host "custom invalid ca cert with defaults"
# should pass
$process = Start-Process -FilePath "node" -ArgumentList "..\..\..\..\packages\bruno-cli\bin\bru.js run .\request.bru --output junit4.xml --cacert ..\server\certs\ca-key.pem --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
[xml]$xml4 = Get-Content junit4.xml
$testsuites4 = if ($xml4.testsuites) { $xml4.testsuites.testsuite } else { $xml4.testsuite }
$errorCount4 = ($testsuites4 | Where-Object { $_.errors -eq "0" } | Measure-Object).Count
if ($errorCount4 -ne 1) { exit 1 }

View File

@@ -0,0 +1,17 @@
name: 'Run SSL E2E Tests - Windows'
description: 'Run SSL E2E tests on Windows'
runs:
using: 'composite'
steps:
- name: Run E2E tests
shell: pwsh
run: |
npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-windows
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,25 @@
name: 'Setup CA Certificates - Windows'
description: 'Setup CA certificates and start test server for custom CA certs tests on Windows'
runs:
using: 'composite'
steps:
- name: Setup CA certificates
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
Set-Location tests\ssl\custom-ca-certs\server
Write-Host "running certificate setup"
node scripts/generate-certs.js
- name: Start test server
shell: pwsh
run: |
Set-StrictMode -Version Latest
Set-Location tests\ssl\custom-ca-certs\server
Write-Host "starting server in background"
Start-Process -FilePath "node" -ArgumentList "index.js" -PassThru -WindowStyle Hidden

View File

@@ -25,8 +25,8 @@ jobs:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'

91
.github/workflows/ssl-tests.yml vendored Normal file
View File

@@ -0,0 +1,91 @@
name: SSL Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
tests-for-linux:
name: SSL Tests - Linux
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v5
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/ssl/linux/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/linux/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
tests-for-macos:
name: SSL Tests - macOS
timeout-minutes: 60
runs-on: macos-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v5
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/ssl/macos/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/macos/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/macos/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/macos/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/macos/run-ssl-e2e-tests
tests-for-windows:
name: SSL Tests - Windows
timeout-minutes: 60
runs-on: windows-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v5
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/windows/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/windows/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/windows/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/windows/run-ssl-e2e-tests

View File

@@ -13,8 +13,8 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -34,6 +34,8 @@ jobs:
- name: Lint Check
run: npm run lint
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
# tests
- name: Test Package bruno-js
@@ -64,8 +66,8 @@ jobs:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
@@ -83,6 +85,11 @@ jobs:
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-filestore
- name: Run Local Testbench
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Run tests
run: |
cd packages/bruno-tests/collection
@@ -101,8 +108,8 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: v22.11.x
- name: Install dependencies

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx nano-staged

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 KiB

After

Width:  |  Height:  |  Size: 813 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 KiB

View File

@@ -0,0 +1,470 @@
# Playwright Testing Guide for Bruno
This guide explains how to create and run Playwright test cases for the Bruno application using the UI.
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Creating Tests Using Codegen](#creating-tests-using-codegen)
- [Manual Test Creation](#manual-test-creation)
- [Test Structure and Organization](#test-structure-and-organization)
- [Available Test Fixtures](#available-test-fixtures)
- [Running Tests](#running-tests)
- [Best Practices](#best-practices)
- [Examples](#examples)
- [Troubleshooting](#troubleshooting)
## Overview
Bruno uses Playwright for end-to-end testing of its Electron application. The testing setup includes custom fixtures for Electron app testing and utilities for managing test data.
## Prerequisites
- Node.js installed
- All dependencies installed (`npm install`)
- Electron app can be built and run
## Creating Tests Using Codegen
The easiest way to create tests is using Playwright's codegen feature, which records your UI interactions and generates test code.
### Using the Built-in Codegen Script
```bash
# Generate a test with a specific name
npm run test:codegen my-new-test
# Generate a test without specifying a name (will prompt for input)
npm run test:codegen
```
### What Happens During Codegen
1. The Electron app launches automatically
2. Playwright Inspector opens in a separate window
3. You interact with the Bruno UI
4. Actions are recorded and converted to test code
5. The generated test file is saved in `e2e-tests/`
### Codegen Workflow
1. **Start Recording**: Run the codegen command
2. **Interact with UI**: Perform the actions you want to test
3. **Add Assertions**: Use the inspector to add assertions
4. **Save Test**: The test file is automatically generated
5. **Review and Refine**: Edit the generated test as needed
## Manual Test Creation
You can also create tests manually by following the established patterns.
### Basic Test Structure
```typescript
import { test, expect } from '../../playwright';
test('Test description', async ({ page }) => {
// Test steps here
await page.getByLabel('Some Label').click();
// Assertions
await expect(page.getByText('Expected Text')).toBeVisible();
});
```
### Test with Temporary Data
```typescript
import { test, expect } from '../../playwright';
test('Test with temporary data', async ({ page, createTmpDir }) => {
// Create temporary directory for test data
const testDir = await createTmpDir('test-collection');
// Test steps
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Location').fill(testDir);
// Assertions
await expect(page.getByText('test-collection')).toBeVisible();
});
```
## Test Structure and Organization
### Directory Structure
```
e2e-tests/
├── 001-sanity-tests/ # Basic functionality tests
│ ├── 001-home-screen.spec.ts
│ └── 002-create-new-collection-and-new-request.spec.ts
├── 002-feature-tests/ # Specific feature tests
├── 003-integration-tests/ # Complex workflow tests
└── bruno-testbench/ # Test utilities and helpers
```
### Naming Conventions
- **Files**: Use descriptive names with `.spec.ts` extension
- **Tests**: Use clear, descriptive test names
- **Folders**: Use numbered prefixes for ordering
### Test File Template
```typescript
import { test, expect } from '../../playwright';
test.describe('Feature Name', () => {
test('should perform specific action', async ({ page }) => {
// Arrange
// Act
// Assert
});
test('should handle error case', async ({ page }) => {
// Test error scenarios
});
});
```
## Available Test Fixtures
The Bruno Playwright setup provides several custom fixtures:
### Core Fixtures
- `page`: Main page for testing
- `context`: Browser context
- `electronApp`: Electron application instance
### Utility Fixtures
- `createTmpDir`: Creates temporary directories for test data
- `newPage`: Creates a new page instance
- `pageWithUserData`: Page with custom user data
- `launchElectronApp`: Launches a new Electron app instance
- `reuseOrLaunchElectronApp`: Reuses existing app or launches new one
### Using Fixtures
```typescript
test('Test with multiple fixtures', async ({ page, createTmpDir, electronApp }) => {
const testDir = await createTmpDir('test-data');
// Your test logic here
});
```
## Running Tests
### Basic Commands
```bash
# Run all tests
npm run test:e2e
# Run specific test file
npx playwright test e2e-tests/001-sanity-tests/001-home-screen.spec.ts
# Run tests in a specific folder
npx playwright test e2e-tests/001-sanity-tests/
```
### Advanced Options
```bash
# Run with UI mode (for debugging)
npx playwright test --ui
# Run in headed mode (see browser)
npx playwright test --headed
# Run with specific browser
npx playwright test --project="Bruno Electron App"
# Run with debugging
npx playwright test --debug
# Run with trace recording
npx playwright test --trace on
```
### CI/CD Integration
```bash
# Install browsers for CI
npx playwright install
# Run tests in CI mode
npm run test:e2e
```
## Best Practices
### 1. Use Semantic Selectors
**Preferred:**
```typescript
await page.getByRole('button', { name: 'Create' }).click();
await page.getByLabel('Collection Name').fill('test');
await page.getByText('Success message').toBeVisible();
```
**Avoid:**
```typescript
await page.locator('.btn-primary').click();
await page.locator('#collection-name').fill('test');
```
### 2. Create Isolated Tests
Each test should be independent and not rely on other tests:
```typescript
test('should create collection', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('collection-test');
// Test creates its own data
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Location').fill(testDir);
// Clean up happens automatically via createTmpDir
});
```
### 3. Add Meaningful Assertions
Always verify the expected outcomes:
```typescript
test('should save request successfully', async ({ page }) => {
// Arrange
await page.getByLabel('Create Collection').click();
// Act
await page.getByRole('button', { name: 'Save' }).click();
// Assert
await expect(page.getByText('Request saved successfully')).toBeVisible();
await expect(page.getByRole('tab', { name: 'GET request' })).toBeVisible();
});
```
### 4. Handle Async Operations
```typescript
test('should wait for network requests', async ({ page }) => {
// Wait for specific network request
await page.waitForResponse((response) => response.url().includes('/api/endpoint'));
// Or wait for element to be stable
await page.waitForSelector('[data-testid="loading"]', { state: 'hidden' });
});
```
### 5. Use Test Data Management
```typescript
test('should work with test data', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('test-data');
// Create test files
await fs.writeFile(path.join(testDir, 'test.bru'), testContent);
// Use in test
await page.getByLabel('Open Collection').click();
await page.getByText(testDir).click();
});
```
## Examples
### Example 1: Basic Collection Creation
```typescript
import { test, expect } from '../../playwright';
test('should create a new collection', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('new-collection');
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('My Test Collection');
await page.getByLabel('Location').fill(testDir);
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByText('My Test Collection')).toBeVisible();
});
```
### Example 2: Request Creation and Execution
```typescript
import { test, expect } from '../../playwright';
test('should create and execute HTTP request', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('request-test');
// Create collection
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('Request Test');
await page.getByLabel('Location').fill(testDir);
await page.getByRole('button', { name: 'Create' }).click();
// Create request
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('Test Request');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('http://localhost:8081/ping');
await page.getByRole('button', { name: 'Create' }).click();
// Execute request
await page.locator('#send-request').getByRole('img').nth(2).click();
// Verify response
await expect(page.getByRole('main')).toContainText('200 OK');
});
```
### Example 3: Environment Management
```typescript
import { test, expect } from '../../playwright';
test('should create and use environment variables', async ({ page, createTmpDir }) => {
const testDir = await createTmpDir('env-test');
// Setup collection
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').fill('Environment Test');
await page.getByLabel('Location').fill(testDir);
await page.getByRole('button', { name: 'Create' }).click();
// Create environment
await page.getByRole('button', { name: 'Environments' }).click();
await page.getByRole('button', { name: 'Add Environment' }).click();
await page.getByLabel('Environment Name').fill('Development');
await page.getByRole('button', { name: 'Create' }).click();
// Add variable
await page.getByRole('button', { name: 'Add Variable' }).click();
await page.getByLabel('Variable Name').fill('API_URL');
await page.getByLabel('Variable Value').fill('http://localhost:3000');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('API_URL')).toBeVisible();
});
```
## Troubleshooting
### Common Issues
1. **Electron App Not Starting**
```bash
# Ensure dependencies are installed
npm install
# Try running the app manually first
npm run dev:electron
```
2. **Tests Timing Out**
```typescript
// Increase timeout for specific test
test('slow test', async ({ page }) => {
test.setTimeout(60000); // 60 seconds
// Test steps
});
```
3. **Element Not Found**
```typescript
// Wait for element to be present
await page.waitForSelector('[data-testid="element"]');
// Or use more specific selectors
await page.getByRole('button', { name: 'Exact Button Text' }).click();
```
4. **Flaky Tests**
```typescript
// Use stable selectors
await page.getByTestId('stable-id').click();
// Wait for state changes
await page.waitForLoadState('networkidle');
```
### Debug Mode
```bash
# Run with debug mode
npx playwright test --debug
# Run specific test in debug mode
npx playwright test --debug e2e-tests/001-sanity-tests/001-home-screen.spec.ts
```
### Trace Analysis
```bash
# Run with trace recording
npx playwright test --trace on
# View trace in browser
npx playwright show-trace test-results/trace-*.zip
```
## Configuration
The Playwright configuration is in `playwright.config.ts`:
```typescript
export default defineConfig({
testDir: './e2e-tests',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: process.env.CI ? undefined : 1,
projects: [
{
name: 'Bruno Electron App'
}
],
webServer: [
{
command: 'npm run dev:web',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
},
{
command: 'npm start --workspace=packages/bruno-tests',
url: 'http://localhost:8081/ping',
reuseExistingServer: !process.env.CI
}
]
});
```
## Additional Resources
- [Playwright Documentation](https://playwright.dev/)
- [Playwright Test API](https://playwright.dev/docs/api/class-test)
- [Electron Testing with Playwright](https://playwright.dev/docs/api/class-electronapplication)
- [Bruno Project Structure](../readme.md)
---
For questions or issues with testing, please refer to the project's contributing guidelines or create an issue in the repository.

View File

@@ -74,10 +74,13 @@ flatpak install com.usebruno.Bruno
# على نظام Linux عبر Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -59,10 +59,13 @@ snap install bruno
# Apt এর মাধ্যমে লিনাক্সে
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -37,13 +37,37 @@ Bruno 直接在您的电脑文件夹中存储您的 API 信息。我们使用纯
Bruno 仅限离线使用。我们计划永不向 Bruno 添加云同步功能。我们重视您的数据隐私,并认为它应该留在您的设备上。阅读我们的长期愿景 [点击查看](https://github.com/usebruno/bruno/discussions/269)
[下载 Bruno](https://www.usebruno.com/downloads)
📢 观看我们在印度 FOSS 3.0 会议上的最新演讲 [点击查看](https://www.youtube.com/watch?v=7bSMFpbcPiY)
![bruno](../../assets/images/landing-2.png) <br /><br />
### 安装
## 商业版本 ✨
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) Mac、Windows 和 Linux 的可执行文件
我们的大多数功能都是免费且开源的
我们致力于在 [开源与可持续性发展](https://github.com/usebruno/bruno/discussions/269) 之间取得和谐的平衡
欢迎使用我们的 [付费版本](https://www.usebruno.com/pricing) ,看看附加的功能是否对您或团队有所帮助! <br/>
## 目录
- [安装](#安装)
- [特性](#特性)
- [跨平台使用 🖥️](#跨平台使用-)
- [通过Git协作 👩‍💻🧑‍💻](#通过git协作-)
- [重要链接 📌](#重要链接-)
- [展示 🎥](#展示-)
- [分享评价 📣](#分享评价-)
- [发布到新的包管理器](#发布到新的包管理器)
- [联系方式 🌐](#联系方式-)
- [商标](#商标)
- [贡献 👩‍💻🧑‍💻](#贡献-)
- [作者](#作者)
- [许可证 📄](#许可证-)
## 安装
Bruno 可以在我们的 [网站上下载](https://www.usebruno.com/downloads) 适用于Mac、Windows 和 Linux 的可执行文件。
您也可以通过包管理器如 Homebrew、Chocolatey、Scoop、Snap 和 Apt 安装 Bruno。
@@ -58,79 +82,71 @@ choco install bruno
scoop bucket add extras
scoop install bruno
# 在 Windows 上用 winget 安装
winget install Bruno.Bruno
# 在 Linux 上用 Snap 安装
snap install bruno
# 在 Linux 上用 Flatpak 安装
flatpak install com.usebruno.Bruno
# 在 Linux 上用 Apt 安装
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### 在 Mac 上通过 Homebrew 安装 🖥️
## 特性
### 跨平台使用 🖥️
![bruno](../../assets/images/run-anywhere.png) <br /><br />
### Collaborate 安装 👩‍💻🧑‍💻
### 通过Git协作 👩‍💻🧑‍💻
或者任何您选择的版本控制系统
![bruno](../../assets/images/version-control.png) <br /><br />
### 重要链接 📌
## 重要链接 📌
- [我们的愿景](https://github.com/usebruno/bruno/discussions/269)
- [路线图](https://github.com/usebruno/bruno/discussions/384)
- [路线图](https://www.usebruno.com/roadmap)
- [文档](https://docs.usebruno.com)
- [Stack Overflow](https://stackoverflow.com/questions/tagged/bruno)
- [网站](https://www.usebruno.com)
- [价格](https://www.usebruno.com/pricing)
- [下载](https://www.usebruno.com/downloads)
- [GitHub 赞助](https://github.com/sponsors/helloanoop).
### 展示 🎥
## 展示 🎥
- [Testimonials](https://github.com/usebruno/bruno/discussions/343)
- [Knowledge Hub](https://github.com/usebruno/bruno/discussions/386)
- [Scriptmania](https://github.com/usebruno/bruno/discussions/385)
### 支持 ❤️
如果您喜欢 Bruno 并想支持我们的开源工作,请考虑通过 [GitHub Sponsors](https://github.com/sponsors/helloanoop) 来赞助我们。
### 分享评价 📣
## 分享评价 📣
如果 Bruno 在您的工作和团队中帮助了您,请不要忘记在我们的 GitHub 讨论上分享您的 [评价](https://github.com/usebruno/bruno/discussions/343)
### 发布到新的包管理器
## 发布到新的包管理器
有关更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
如需了解更多信息,请参见 [此处](../publishing/publishing_cn.md) 。
### 贡献 👩‍💻🧑‍💻
我很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
### 作者
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
### 联系方式 🌐
## 联系方式 🌐
[𝕏 (Twitter)](https://twitter.com/use_bruno) <br />
[Website](https://www.usebruno.com) <br />
[Discord](https://discord.com/invite/KgcZUncpjq) <br />
[LinkedIn](https://www.linkedin.com/company/usebruno)
### 商标
## 商标
**名称**
@@ -140,6 +156,20 @@ sudo apt update && sudo apt install bruno
Logo 源自 [OpenMoji](https://openmoji.org/library/emoji-1F436/). License: CC [BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
### 许可证 📄
## 贡献 👩‍💻🧑‍💻
很高兴您希望改进 bruno。请查看 [贡献指南](../contributing/contributing_cn.md)。
即使您无法通过代码做出贡献,我们仍然欢迎您提出 BUG 和新的功能需求。
## 作者
<div align="center">
<a href="https://github.com/usebruno/bruno/graphs/contributors">
<img src="https://contrib.rocks/image?repo=usebruno/bruno" />
</a>
</div>
## 许可证 📄
[MIT](../../license.md)

View File

@@ -78,10 +78,13 @@ flatpak install com.usebruno.Bruno
# Auf Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -75,10 +75,13 @@ flatpak install com.usebruno.Bruno
# En Linux con Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -63,10 +63,13 @@ snap install bruno
# Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -75,12 +75,14 @@ flatpak install com.usebruno.Bruno
# Linux पर Apt के माध्यम से
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
कई प्लेटफार्मों पर चलाएं 🖥️
<br /><br />
@@ -148,4 +150,3 @@ Scriptmania
लाइसेंस 📄
MIT

View File

@@ -59,10 +59,13 @@ snap install bruno
# Su Linux tramite Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -78,10 +78,13 @@ flatpak install com.usebruno.Bruno
# LinuxでAptを使ってインストール
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -77,12 +77,14 @@ flatpak install com.usebruno.Bruno
# Linux-ზე Apt-ის საშუალებით
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### პლატფორმებს შორის მუშაობა 🖥️

View File

@@ -59,10 +59,13 @@ snap install bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -61,12 +61,14 @@ flatpak install com.usebruno.Bruno
# Op Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Draai op meerdere platformen 🖥️

View File

@@ -69,10 +69,13 @@ flatpak install com.usebruno.Bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -76,10 +76,13 @@ flatpak install com.usebruno.Bruno
# No Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -59,10 +59,13 @@ snap install bruno
# Pe Linux cu Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -63,10 +63,13 @@ snap install bruno
# Apt aracılığıyla Linux'ta
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -63,10 +63,13 @@ snap install bruno
# 在 Linux 上使用 Apt 安裝
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install gpg curl
curl -fsSL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x9FA6017ECABE0266" \
| gpg --dearmor \
| sudo tee /etc/apt/keyrings/bruno.gpg > /dev/null
sudo chmod 644 /etc/apt/keyrings/bruno.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" \
| sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```

View File

@@ -1,31 +0,0 @@
import { test, expect } from '../../playwright';
test('Create new collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('#request-url .CodeMirror').click();
await page.locator('textarea').fill('/ping');
await page.locator('#send-request').getByRole('img').nth(2).click();
await expect(page.getByRole('main')).toContainText('200 OK');
await page.getByRole('tab', { name: 'GET r1' }).locator('circle').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByText('GETr1').click();
await page.getByRole('button', { name: 'Clear response' }).click();
await page.locator('body').press('ControlOrMeta+Enter');
await expect(page.getByRole('main')).toContainText('200 OK');
});

View File

@@ -1,33 +0,0 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByRole('button', { name: 'Save' }).click();
await page.getByText('ping', { exact: true }).click();
await page.getByText('No Environment').click();
await page.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByRole('tooltip').locator('div').filter({ hasText: 'Env' }).nth(3).click();
await newPage.locator('div').filter({ hasText: /^Env$/ }).nth(3).click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -1,40 +0,0 @@
import { test, expect } from '../../playwright';
test.describe.serial('Persistent Environment Test', () => {
test.setTimeout(2 * 10 * 1000);
test('add env using script', async ({ pageWithUserData: page, restartApp }) => {
await page.locator('#sidebar-collection-name').click();
await page.getByText('ping2', { exact: true }).click();
await page.getByText('Env', { exact: true }).click();
await page.getByText('Stage', { exact: true }).click();
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.waitForTimeout(1000);
await page
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await page.getByText('Configure', { exact: true }).click();
await expect(page.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).toBeVisible();
await page.getByText('×').click();
const newApp = await restartApp();
const newPage = await newApp.firstWindow();
await newPage.locator('#sidebar-collection-name').click();
await newPage.getByRole('button', { name: 'Save' }).click();
await newPage.getByText('ping2', { exact: true }).click();
await newPage.getByText('No Environment').click();
await newPage.getByText('Stage').click();
await newPage
.locator('div')
.filter({ hasText: /^Stage$/ })
.nth(3)
.click();
await newPage.getByText('Configure', { exact: true }).click();
await expect(newPage.getByRole('row', { name: 'persistent-env-test' }).getByRole('cell').nth(3)).not.toBeVisible();
await newPage.getByText('×').click();
await newPage.waitForTimeout(1000);
await newPage.close();
});
});

View File

@@ -1,4 +0,0 @@
vars {
host: https://testbench-sanity.usebruno.com
persistent-env-test: persistent-env-test-value
}

View File

@@ -1,6 +0,0 @@
{
"maximized": true,
"lastOpenedCollections": [
"{{projectRoot}}/e2e-tests/persistent-env-tests/collection"
]
}

View File

@@ -1,8 +1,70 @@
// eslint.config.js
const { defineConfig } = require("eslint/config");
const globals = require("globals");
const { fixupPluginRules } = require('@eslint/compat');
const eslintPluginDiff = require('eslint-plugin-diff');
module.exports = defineConfig([
let stylistic;
const runESMImports = async () => {
stylistic = await import('@stylistic/eslint-plugin').then(d => d.default);
};
module.exports = runESMImports().then(() => defineConfig([
{
plugins: {
'diff': fixupPluginRules(eslintPluginDiff),
'@stylistic': stylistic,
},
languageOptions: {
parser: require('@typescript-eslint/parser'),
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module'
}
},
files: [
'./eslint.config.js',
'tests/**/*.{ts,js}',
'packages/bruno-app/**/*.{js,jsx,ts}',
'packages/bruno-app/src/test-utils/mocks/codemirror.js',
'packages/bruno-cli/**/*.js',
'packages/bruno-common/**/*.ts',
'packages/bruno-converters/**/*.js',
'packages/bruno-electron/**/*.js',
'packages/bruno-filestore/**/*.ts',
'packages/bruno-js/**/*.js',
'packages/bruno-lang/**/*.js',
'packages/bruno-requests/**/*.ts',
'packages/bruno-requests/**/*.js',
],
processor: 'diff/diff',
rules: {
...stylistic.configs.customize({
indent: 2,
quotes: 'single',
semi: true,
jsx: true,
}).rules,
'@stylistic/comma-dangle': ['error', 'never'],
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: true }],
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/curly-newline': ['error', {
multiline: true,
minElements: 2,
consistent: true,
}],
'@stylistic/function-paren-newline': ['error', 'never'],
'@stylistic/array-bracket-spacing': ['error', 'never'],
'@stylistic/arrow-spacing': ['error', { before: true, after: true }],
'@stylistic/function-call-spacing': ['error', 'never'],
'@stylistic/multiline-ternary': ['off'],
'@stylistic/padding-line-between-statements': ['off'],
'@stylistic/semi-style': ['error', 'last'],
'@stylistic/max-len': ['off'],
'@stylistic/jsx-one-expression-per-line': ['off']
},
},
{
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
ignores: ["**/*.config.js", "**/public/**/*"],
@@ -197,4 +259,4 @@ module.exports = defineConfig([
"no-undef": "error",
},
},
]);
]));

1104
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,20 +19,25 @@
],
"homepage": "https://usebruno.com",
"devDependencies": {
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"eslint-plugin-diff": "^2.0.3",
"fs-extra": "^11.1.1",
"globals": "^16.1.0",
"husky": "^9.1.7",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"nano-staged": "^0.8.0",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
@@ -65,9 +70,17 @@
"build:electron:snap": "./scripts/build-electron.sh snap",
"watch:common": "npm run watch --workspace=packages/bruno-common",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
"lint": "node --max_old_space_size=4096 $(npx which eslint)",
"lint:fix": "node --max_old_space_size=4096 $(npx which eslint) --fix",
"prepare": "husky"
},
"nano-staged": {
"*.{js,ts,jsx}": [
"npm run lint:fix"
]
},
"overrides": {
"rollup": "3.29.5",

View File

@@ -16,6 +16,7 @@
"@prantlf/jsonlint": "^16.0.0",
"@reduxjs/toolkit": "^1.8.0",
"@tabler/icons": "^1.46.0",
"@testing-library/user-event": "^14.6.1",
"@tippyjs/react": "^4.2.6",
"@usebruno/common": "0.1.0",
"@usebruno/graphql-docs": "0.1.0",
@@ -92,7 +93,7 @@
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"autoprefixer": "10.4.20",

View File

@@ -109,6 +109,17 @@ const StyledWrapper = styled.div`
text-decoration:unset;
}
.cm-search-line-highlight {
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
}
.cm-search-match {
background: rgba(255, 193, 7, 0.25);
}
.cm-search-current {
background: rgba(255, 193, 7, 0.4);
}
`;
export default StyledWrapper;

View File

@@ -14,6 +14,7 @@ import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -37,6 +38,10 @@ export default class CodeEditor extends React.Component {
expr: true,
asi: true
};
this.state = {
searchBarVisible: false
};
}
componentDidMount() {
@@ -45,7 +50,7 @@ export default class CodeEditor extends React.Component {
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
lineWrapping: true,
lineWrapping: this.props.enableLineWrapping ?? true,
tabSize: TAB_SIZE,
mode: this.props.mode || 'application/ld+json',
brunoVarInfo: {
@@ -83,24 +88,14 @@ export default class CodeEditor extends React.Component {
}
},
'Cmd-F': (cm) => {
if (this._isSearchOpen()) {
// replace the older search component with the new one
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
search && search.remove();
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
},
'Ctrl-F': (cm) => {
if (this._isSearchOpen()) {
// replace the older search component with the new one
const search = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
search && search.remove();
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
cm.execCommand('findPersistent');
this._bindSearchHandler();
this._appendSearchResultsCount();
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
@@ -129,6 +124,11 @@ export default class CodeEditor extends React.Component {
} else {
this.editor.toggleComment();
}
},
'Esc': () => {
if (this.state.searchBarVisible) {
this.setState({ searchBarVisible: false });
}
}
},
foldOptions: {
@@ -186,6 +186,8 @@ export default class CodeEditor extends React.Component {
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
editor.on('scroll', this.onScroll);
editor.scrollTo(null, this.props.initialScroll);
this.addOverlay();
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
@@ -230,19 +232,28 @@ export default class CodeEditor extends React.Component {
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.initialScroll !== prevProps.initialScroll) {
this.editor.scrollTo(null, this.props.initialScroll);
}
if (this.props.enableLineWrapping !== prevProps.enableLineWrapping) {
this.editor.setOption('lineWrapping', this.props.enableLineWrapping);
}
if (this.props.mode !== prevProps.mode) {
this.editor.setOption('mode', this.props.mode);
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor.off('scroll', this.onScroll);
this.editor = null;
}
this._unbindSearchHandler();
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
}
render() {
@@ -255,10 +266,18 @@ export default class CodeEditor extends React.Component {
aria-label="Code Editor"
font={this.props.font}
fontSize={this.props.fontSize}
ref={(node) => {
this._node = node;
}}
/>
>
<CodeMirrorSearch
visible={this.state.searchBarVisible}
editor={this.editor}
onClose={() => this.setState({ searchBarVisible: false })}
/>
<div
className={`editor-container${this.state.searchBarVisible ? ' search-bar-visible' : ''}`}
ref={(node) => { this._node = node; }}
style={{ height: '100%', width: '100%' }}
/>
</StyledWrapper>
);
}
@@ -271,6 +290,8 @@ export default class CodeEditor extends React.Component {
this.editor.setOption('mode', 'brunovariables');
};
onScroll = (event) => this.props.onScroll?.(event);
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
@@ -280,67 +301,4 @@ export default class CodeEditor extends React.Component {
}
}
};
_isSearchOpen = () => {
return document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
};
/**
* Bind handler to search input to count number of search results
*/
_bindSearchHandler = () => {
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput) {
searchInput.addEventListener('input', this._countSearchResults);
}
};
/**
* Unbind handler to search input to count number of search results
*/
_unbindSearchHandler = () => {
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput) {
searchInput.removeEventListener('input', this._countSearchResults);
}
};
/**
* Append search results count to search dialog
*/
_appendSearchResultsCount = () => {
const dialog = document.querySelector('.CodeMirror-dialog.CodeMirror-dialog-top');
if (dialog) {
const searchResultsCount = document.createElement('span');
searchResultsCount.id = this.searchResultsCountElementId;
dialog.appendChild(searchResultsCount);
this._countSearchResults();
}
};
/**
* Count search results and update state
*/
_countSearchResults = () => {
let count = 0;
const searchInput = document.querySelector('.CodeMirror-search-field');
if (searchInput && searchInput.value.length > 0) {
// Escape special characters in search input to prevent RegExp crashes. Fixes #3051
const text = new RegExp(escapeRegExp(searchInput.value), 'gi');
const matches = this.editor.getValue().match(text);
count = matches ? matches.length : 0;
}
const searchResultsCountElement = document.querySelector(`#${this.searchResultsCountElementId}`);
if (searchResultsCountElement) {
searchResultsCountElement.innerText = `${count} results`;
}
};
}

View File

@@ -0,0 +1,99 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.bruno-search-bar {
position: absolute;
top: 8px;
right: 8px;
z-index: 20;
display: flex;
align-items: center;
flex-wrap: nowrap;
padding: 0 2px;
min-height: 36px;
background: ${(props) => props.theme.sidebar.search.bg} !important;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.sidebar.search.bg} !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
width: auto;
min-width: 180px;
max-width: 320px;
}
.bruno-search-bar input {
min-width: 80px;
background: transparent;
color: inherit;
border: none;
outline: none;
padding: 1px 2px;
font-size: 13px;
margin: 0 1px;
height: 28px;
}
.searchbar-icon-btn {
background: none;
border: none;
padding: 0 1px;
margin: 0 1px;
cursor: pointer;
color: #aaa;
border-radius: 3px;
height: 18px;
width: 18px;
display: flex;
align-items: center;
justify-content: center;
}
.searchbar-result-count {
min-width: 28px;
text-align: center;
font-size: 11px;
color: #aaa;
margin: 0 8px 0 1px;
white-space: nowrap;
}
.bruno-search-bar.compact {
background: ${(props) => props.theme.codemirror.bg};
color: ${(props) => props.theme.codemirror.text || props.theme.text};
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
border-radius: 4px;
padding: 1px 3px;
min-height: 22px;
display: flex;
align-items: center;
gap: 0;
}
.bruno-search-bar input {
background: transparent;
color: inherit;
border: none;
outline: none;
font-size: 13px;
padding: 1px 2px;
min-width: 80px;
}
.searchbar-icon-btn:focus {
outline: 1px solid ${(props) => props.theme.codemirror.border};
}
.bruno-search-bar, .bruno-search-bar input {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
.cm-search-line-highlight {
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
}
.searchbar-icon-btn.active {
color: #f39c12 !important;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,201 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { IconRegex, IconArrowUp, IconArrowDown, IconX, IconLetterCase, IconLetterW } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import useDebounce from 'hooks/useDebounce';
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const CodeMirrorSearch = ({ visible, editor, onClose }) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
const [caseSensitive, setCaseSensitive] = useState(false);
const [wholeWord, setWholeWord] = useState(false);
const [matchIndex, setMatchIndex] = useState(0);
const [matchCount, setMatchCount] = useState(0);
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const debouncedSearchText = useDebounce(searchText, 150);
const memoizedMatches = useMemo(() => {
if (!editor || !visible) return [];
if (!debouncedSearchText) return [];
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
} catch {
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(debouncedSearchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = debouncedSearchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
const doSearch = useCallback((newIndex = 0) => {
if (!editor) return;
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Clear previous line highlight
if (searchLineHighlight.current !== null) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
}
if (!debouncedSearchText) {
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
return;
}
try {
const matches = memoizedMatches;
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
if (matches.length) {
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
} else {
searchLineHighlight.current = null;
}
setMatchCount(matches.length);
setMatchIndex(matchIndex);
searchMatches.current = matches;
} catch (e) {
console.error('Search error:', e);
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useEffect(() => {
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
const handleSearchBarClose = useCallback(() => {
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
if (searchLineHighlight.current !== null && editor) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
}
searchMatches.current = [];
if (onClose) onClose();
// Focus the editor after closing the search bar
if (editor) {
setTimeout(() => editor.focus(), 0);
}
}, [editor, onClose]);
const handleSearchTextChange = (text) => {
setSearchText(text);
setMatchIndex(0);
};
const handleToggleRegex = () => {
setRegex((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleCase = () => {
setCaseSensitive((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleWholeWord = () => {
setWholeWord((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleNext = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
let next = (matchIndex + 1) % searchMatches.current.length;
setMatchIndex(next);
doSearch(next);
};
const handlePrev = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
setMatchIndex(prev);
doSearch(prev);
};
if (!visible) return null;
return (
<StyledWrapper>
<div className="bruno-search-bar compact">
<input
autoFocus
type="text"
value={searchText}
onChange={(e) => handleSearchTextChange(e.target.value)}
placeholder="Search..."
spellCheck={false}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) handleNext();
if (e.key === 'Enter' && e.shiftKey) handlePrev();
if (e.key === 'Escape') handleSearchBarClose();
}}
/>
<span className="searchbar-result-count">{matchCount > 0 ? `${matchIndex + 1} / ${matchCount}` : '0 results'}</span>
<ToolHint text="Regex search" toolhintId="searchbar-regex-toolhint" place="top">
<button className={`searchbar-icon-btn ${regex ? 'active' : ''}`} onClick={handleToggleRegex}><IconRegex size={16} /></button>
</ToolHint>
<ToolHint text="Case sensitive" toolhintId="searchbar-case-toolhint" place="top">
<button className={`searchbar-icon-btn ${caseSensitive ? 'active' : ''}`} onClick={handleToggleCase}><IconLetterCase size={14} /></button>
</ToolHint>
<ToolHint text="Whole word" toolhintId="searchbar-wholeword-toolhint" place="top">
<button className={`searchbar-icon-btn ${wholeWord ? 'active' : ''}`} onClick={handleToggleWholeWord}><IconLetterW size={14} /></button>
</ToolHint>
<button className="searchbar-icon-btn" title="Previous" onClick={handlePrev}><IconArrowUp size={14} /></button>
<button className="searchbar-icon-btn" title="Next" onClick={handleNext}><IconArrowDown size={14} /></button>
<button className="searchbar-icon-btn" title="Close" onClick={handleSearchBarClose}><IconX size={14} /></button>
</div>
</StyledWrapper>
);
};
export default CodeMirrorSearch;

View File

@@ -1,263 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconFile, IconFileImport, IconAlertCircle } from '@tabler/icons';
import { getRelativePath, getBasename, getDirPath } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import { existsSync, resolvePath } from '../../../utils/filesystem';
const GrpcSettings = ({ collection }) => {
const dispatch = useDispatch();
const {
brunoConfig: { grpc: grpcConfig = {} }
} = collection;
const fileInputRef = useRef(null);
const [protoFileValidity, setProtoFileValidity] = useState({});
const formik = useFormik({
enableReinitialize: true,
initialValues: {
protoFiles: grpcConfig.protoFiles || []
},
onSubmit: (newGrpcConfig) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.grpc = newGrpcConfig;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
toast.success('gRPC settings updated');
}
});
// Get file path using the ipcRenderer
const getProtoFile = (event) => {
const files = event?.files;
if (files && files.length > 0) {
const newProtoFiles = [...formik.values.protoFiles];
for (let i = 0; i < files.length; i++) {
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const protoFileObj = {
path: relativePath,
type: 'file'
};
// Check if this path already exists
const exists = newProtoFiles.some(pf => pf.path === protoFileObj.path);
if (!exists) {
newProtoFiles.push(protoFileObj);
}
}
}
formik.setFieldValue('protoFiles', newProtoFiles);
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// Handler for removing a proto file
const handleRemoveProtoFile = (index) => {
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles.splice(index, 1);
formik.setFieldValue('protoFiles', updatedProtoFiles);
};
// Handle the browse button click
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
// Check if a proto file path is valid
const isProtoFileValid = async (protoFile) => {
try {
const absolutePath = await resolvePath(protoFile.path, collection.pathname);
return await existsSync(absolutePath);
} catch (error) {
return false;
}
};
// Validate all proto files and update state
useEffect(() => {
const validateProtoFiles = async () => {
const validityMap = {};
for (const file of formik.values.protoFiles) {
validityMap[file.path] = await isProtoFileValid(file);
}
setProtoFileValidity(validityMap);
};
validateProtoFiles();
}, [formik.values.protoFiles, collection.pathname]);
// Handle replacing an invalid proto file
const handleReplaceProtoFile = (index) => {
if (fileInputRef.current) {
fileInputRef.current.click();
// Store the index to replace after file selection
fileInputRef.current.dataset.replaceIndex = index;
}
};
// Handle file input change
const handleFileInputChange = (e) => {
const replaceIndex = e.target.dataset.replaceIndex;
if (replaceIndex !== undefined) {
// Handle replacement
const files = e.target.files;
if (files && files.length > 0) {
const filePath = window?.ipcRenderer?.getFilePath(files[0]);
if (filePath) {
const relativePath = getRelativePath(filePath, collection.pathname);
const updatedProtoFiles = [...formik.values.protoFiles];
updatedProtoFiles[replaceIndex] = {
path: relativePath,
type: 'file'
};
formik.setFieldValue('protoFiles', updatedProtoFiles);
}
}
delete e.target.dataset.replaceIndex;
} else {
getProtoFile(e.target);
}
};
return (
<StyledWrapper className="h-full w-full">
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="mb-3">
<label className="font-semibold text-sm mb-3 flex items-center" htmlFor="protoFiles">
Add Proto Files
<span id="proto-files-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="proto-files-tooltip"
className="tooltip-mod font-normal"
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
/>
</label>
<div className="flex flex-col">
{/* Hidden file input for file selection */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".proto"
multiple
onChange={handleFileInputChange}
/>
<div className="flex flex-col gap-3">
{/* File selection options */}
<div className="flex flex-col space-y-3">
<div className="flex items-center">
<button
type="button"
className="btn btn-sm btn-secondary flex items-center"
onClick={handleBrowseClick}
>
<IconFileImport size={16} strokeWidth={1.5} className="mr-1" />
Browse for proto files
</button>
</div>
</div>
{/* Divider */}
<div className="border-t border-neutral-600 my-2"></div>
{/* List of added proto files */}
<div>
<div className="text-sm font-semibold mb-2 flex items-center">
<IconFile size={16} strokeWidth={1.5} className="mr-1" />
Added Proto Files ({formik.values.protoFiles.length})
</div>
{formik.values.protoFiles.length === 0 ? (
<div className="text-neutral-500 text-sm italic">No proto files added yet</div>
) : (
<>
{formik.values.protoFiles.some(file => !protoFileValidity[file.path]) && (
<div className="text-xs text-red-500 mb-2 flex items-center bg-red-50 dark:bg-red-900/20 p-2 rounded">
<IconAlertCircle size={14} className="mr-1" />
Some proto files cannot be found at their specified paths. Use the "Replace" option to update their locations.
</div>
)}
<ul className="mt-4">
{formik.values.protoFiles.map((file, index) => {
const isValid = protoFileValidity[file.path];
return (
<li key={index} className="flex items-center available-certificates p-2 rounded-lg mb-2">
<div className="flex items-center w-full justify-between">
<div className="flex w-full items-center">
<IconFile className="mr-2" size={18} strokeWidth={1.5} />
<div
className="overflow-hidden text-ellipsis whitespace-nowrap max-w-[300px] text-sm"
title={file.path}
>
{getBasename(file.path)}
<span className="text-xs text-neutral-500 ml-2">
{getDirPath(file.path)}
</span>
</div>
</div>
<div className="flex w-full items-center justify-end">
{!isValid && (
<div className="flex items-center mr-2">
<IconAlertCircle
size={16}
className="text-red-500"
title="Proto file not found. Click to replace."
/>
<button
type="button"
className="text-xs text-red-500 ml-1 hover:underline"
onClick={() => handleReplaceProtoFile(index)}
>
Replace
</button>
</div>
)}
<button
type="button"
className="remove-certificate ml-2"
onClick={() => handleRemoveProtoFile(index)}
title="Remove file"
>
<IconTrash size={18} strokeWidth={1.5} />
</button>
</div>
</div>
</li>
);
})}
</ul>
</>
)}
</div>
</div>
</div>
</div>
<div className="mt-6">
<button type="submit" className="submit btn btn-sm btn-secondary">
Save
</button>
</div>
</form>
</StyledWrapper>
);
};
export default GrpcSettings;

View File

@@ -5,11 +5,9 @@ import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { updateBrunoConfig } from 'providers/ReduxStore/slices/collections/actions';
import cloneDeep from 'lodash/cloneDeep';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const {
brunoConfig: { presets: presets = {} }
} = collection;
@@ -17,15 +15,10 @@ const PresetsSettings = ({ collection }) => {
const formik = useFormik({
enableReinitialize: true,
initialValues: {
requestType: presets.requestType === 'grpc' && !isGrpcEnabled ? 'http' : presets.requestType || 'http',
requestType: presets.requestType || 'http',
requestUrl: presets.requestUrl || ''
},
onSubmit: (newPresets) => {
// If gRPC is disabled but the preset is set to grpc, change it to http
if (!isGrpcEnabled && newPresets.requestType === 'grpc') {
newPresets.requestType = 'http';
}
const brunoConfig = cloneDeep(collection.brunoConfig);
brunoConfig.presets = newPresets;
dispatch(updateBrunoConfig(brunoConfig, collection.uid));
@@ -70,22 +63,18 @@ const PresetsSettings = ({ collection }) => {
GraphQL
</label>
{isGrpcEnabled && (
<>
<input
id="grpc"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="grpc"
checked={formik.values.requestType === 'grpc'}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
</label>
</>
)}
<input
id="grpc"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={formik.handleChange}
value="grpc"
checked={formik.values.requestType === 'grpc'}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
</label>
</div>
</div>
<div className="mb-3 flex items-center">

View File

@@ -10,4 +10,4 @@ const StyledWrapper = styled.div`
}
`;
export default StyledWrapper;
export default StyledWrapper;

View File

@@ -0,0 +1,336 @@
import React, { useRef } from 'react';
import StyledWrapper from './StyledWrapper';
import {
IconTrash,
IconFile,
IconFileImport,
IconAlertCircle,
IconFolder
} from '@tabler/icons';
import { getBasename } from 'utils/common/path';
import { Tooltip } from 'react-tooltip';
import useProtoFileManagement from '../../../hooks/useProtoFileManagement';
const ProtobufSettings = ({ collection }) => {
const {
protoFiles,
importPaths,
addProtoFileToCollection,
addImportPathToCollection,
toggleImportPath,
browseForProtoFile,
browseForImportDirectory,
removeProtoFileFromCollection,
removeImportPathFromCollection,
replaceImportPathInCollection,
replaceProtoFileInCollection
} = useProtoFileManagement(collection);
const fileInputRef = useRef(null);
// Get file path using the ipcRenderer
const getProtoFile = async (event) => {
const files = event?.files;
if (files && files.length > 0) {
for (let i = 0; i < files.length; i++) {
const filePath = window?.ipcRenderer?.getFilePath(files[i]);
if (filePath) {
await addProtoFileToCollection(filePath);
}
}
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleRemoveProtoFile = async (index) => {
await removeProtoFileFromCollection(index);
};
const handleBrowseClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleReplaceProtoFile = async (index) => {
const result = await browseForProtoFile();
if (result.success) {
await replaceProtoFileInCollection(index, result.filePath);
}
};
const handleReplaceImportPath = async (index) => {
const result = await browseForImportDirectory();
if (result.success) {
await replaceImportPathInCollection(index, result.directoryPath);
}
};
const handleFileInputChange = (e) => {
getProtoFile(e.target);
};
const getImportPath = async () => {
const result = await browseForImportDirectory();
if (result.success) {
await addImportPathToCollection(result.directoryPath);
}
};
const handleRemoveImportPath = async (index) => {
await removeImportPathFromCollection(index);
};
const handleToggleImportPath = async (index) => {
await toggleImportPath(index);
};
const handleBrowseImportPathClick = () => {
getImportPath();
};
return (
<StyledWrapper className="h-full w-full">
{/* Hidden file input for file selection */}
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".proto"
multiple
onChange={handleFileInputChange}
/>
{/* Proto Files Section */}
<div className="mb-6" data-testid="protobuf-proto-files-section">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<label className="font-semibold text-sm flex items-center" htmlFor="protoFiles">
Proto Files (
{protoFiles.length}
)
<span id="proto-files-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="proto-files-tooltip"
className="tooltip-mod font-normal"
html="Keep your proto files within the collection folder or the corresponding git repository to ensure paths remain valid when sharing the collection."
/>
</label>
</div>
</div>
<div>
{protoFiles.some((file) => !file.exists) && (
<div className="text-xs text-red-600 dark:text-red-400 mb-2 flex items-center p-2 rounded" data-testid="protobuf-invalid-files-message">
<IconAlertCircle size={14} className="mr-1" />
Some proto files cannot be found. Use the replace option to update their locations.
</div>
)}
<table className="w-full border-collapse" data-testid="protobuf-proto-files-table">
<thead>
<tr>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
File
</th>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Path
</th>
<th className="text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Actions
</th>
</tr>
</thead>
<tbody>
{protoFiles.length === 0 ? (
<tr>
<td colSpan="3" className="border border-gray-200 dark:border-gray-700 px-3 py-8 text-center">
<div className="flex flex-col items-center">
<IconFile size={24} className="text-gray-400 mb-2" />
<span className="text-sm text-gray-500 dark:text-gray-400">No proto files added</span>
</div>
</td>
</tr>
) : (
protoFiles.map((file, index) => {
const isValid = file.exists;
return (
<tr key={index}>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="flex items-center">
<IconFile size={16} className="text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{getBasename(collection.pathname, file.path)}
</span>
{!isValid && <IconAlertCircle size={12} className="text-red-600 dark:text-red-400 ml-2" />}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="text-xs text-gray-600 dark:text-gray-400 font-mono">
{file.path}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-right">
<div className="flex items-center justify-end space-x-1">
{!isValid && (
<button
type="button"
onClick={() => handleReplaceProtoFile(index)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1 rounded"
title="Replace file"
>
<IconFileImport size={14} />
</button>
)}
<button
type="button"
onClick={() => handleRemoveProtoFile(index)}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 p-1 rounded"
title="Remove file"
data-testid="protobuf-remove-file-button"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
<button type="button" className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleBrowseClick} data-testid="protobuf-add-file-button">
+ Add Proto File
</button>
</div>
</div>
{/* Import Paths Section */}
<div className="mb-6" data-testid="protobuf-import-paths-section">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<label className="font-semibold text-sm flex items-center" htmlFor="importPaths">
Import Paths (
{importPaths.length}
)
<span id="import-paths-tooltip" className="ml-2">
<IconAlertCircle size={16} className="text-gray-500 cursor-pointer" />
</span>
<Tooltip
anchorId="import-paths-tooltip"
className="tooltip-mod font-normal"
html="Add directories that contain proto files to be imported. These paths help resolve import statements in your proto files."
/>
</label>
</div>
</div>
<div>
{importPaths.some((path) => !path.exists) && (
<div className="text-xs text-red-600 dark:text-red-400 mb-2 flex items-center p-2 rounded" data-testid="protobuf-invalid-import-paths-message">
<IconAlertCircle size={14} className="mr-1" />
Some import paths cannot be found at their specified locations.
</div>
)}
<table className="w-full border-collapse" data-testid="protobuf-import-paths-table">
<thead>
<tr>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
</th>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Directory
</th>
<th className="text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Path
</th>
<th className="text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider border border-gray-200 dark:border-gray-700 px-3 py-2">
Actions
</th>
</tr>
</thead>
<tbody>
{importPaths.length === 0 ? (
<tr>
<td colSpan="4" className="border border-gray-200 dark:border-gray-700 px-3 py-8 text-center">
<div className="flex flex-col items-center">
<IconFolder size={24} className="text-gray-400 mb-2" />
<span className="text-sm text-gray-500 dark:text-gray-400">No import paths added</span>
</div>
</td>
</tr>
) : (
importPaths.map((importPath, index) => {
const isValid = importPath.exists;
return (
<tr key={index}>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<input
type="checkbox"
checked={importPath.enabled}
onChange={() => handleToggleImportPath(index)}
className="h-4 w-4 text-gray-600 focus:ring-gray-500 border-gray-300 dark:border-gray-600 rounded"
title={importPath.enabled ? 'Disable this import path' : 'Enable this import path'}
data-testid="protobuf-import-path-checkbox"
/>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="flex items-center">
<IconFolder size={16} className="text-gray-500 dark:text-gray-400 mr-2" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{getBasename(collection.pathname, importPath.path)}
</span>
{!isValid && <IconAlertCircle size={12} className="text-red-600 dark:text-red-400 ml-2" />}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="text-xs text-gray-600 dark:text-gray-400 font-mono">
{importPath.path}
</div>
</td>
<td className="border border-gray-200 dark:border-gray-700 px-3 py-2 text-right">
<div className="flex items-center justify-end space-x-1">
{!isValid && (
<button
type="button"
onClick={() => handleReplaceImportPath(index)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 p-1 rounded"
title="Replace directory"
>
<IconFileImport size={14} />
</button>
)}
<button
type="button"
onClick={() => handleRemoveImportPath(index)}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300 p-1 rounded"
title="Remove import path"
data-testid="protobuf-remove-import-path-button"
>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
<button type="button" className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleBrowseImportPathClick} data-testid="protobuf-add-import-path-button">
+ Add Import Path
</button>
</div>
</div>
</StyledWrapper>
);
};
export default ProtobufSettings;

View File

@@ -13,16 +13,14 @@ import Auth from './Auth';
import Script from './Script';
import Test from './Tests';
import Presets from './Presets';
import Grpc from './Grpc';
import Protobuf from './Protobuf';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import StatusDot from 'components/StatusDot';
import Overview from './Overview/index';
import { useBetaFeature, BETA_FEATURES } from 'utils/beta-features';
const CollectionSettings = ({ collection }) => {
const dispatch = useDispatch();
const isGrpcEnabled = useBetaFeature(BETA_FEATURES.GRPC);
const tab = collection.settingsSelectedTab;
const setTab = (tab) => {
dispatch(
@@ -46,9 +44,13 @@ const CollectionSettings = ({ collection }) => {
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const presets = get(collection, 'brunoConfig.presets', []);
const hasPresets = presets && presets.requestUrl !== "";
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const proxyEnabled = proxyConfig.hostname ? true : false;
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
const grpcConfig = get(collection, 'brunoConfig.grpc', {});
const protobufConfig = get(collection, 'brunoConfig.protobuf', {});
const onProxySettingsUpdate = (config) => {
const brunoConfig = cloneDeep(collection.brunoConfig);
@@ -125,8 +127,8 @@ const CollectionSettings = ({ collection }) => {
/>
);
}
case 'grpc': {
return <Grpc collection={collection} />;
case 'protobuf': {
return <Protobuf collection={collection} />;
}
}
};
@@ -165,21 +167,20 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && <StatusDot />}
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
{isGrpcEnabled && (
<div className={getTabClassname('grpc')} role="tab" onClick={() => setTab('grpc')}>
gRPC
{grpcConfig.protoFiles && grpcConfig.protoFiles.length > 0 && <StatusDot />}
</div>
)}
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
Protobuf
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>
</div>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>

View File

@@ -67,10 +67,10 @@ const RequestTab = ({ request, response }) => {
)}
</div>
{request?.body && (
{request?.data && (
<div className="section">
<h4>Request Body</h4>
<pre className="code-block">{formatBody(request.body)}</pre>
<pre className="code-block">{formatBody(request.data)}</pre>
</div>
)}
</div>
@@ -239,4 +239,4 @@ const RequestDetailsPanel = () => {
);
};
export default RequestDetailsPanel;
export default RequestDetailsPanel;

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,12 @@ import React from 'react';
import Tippy from '@tippyjs/react';
import StyledWrapper from './StyledWrapper';
const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }) => {
const Dropdown = ({ icon, children, onCreate, placement, transparent, visible, ...props }) => {
// When in controlled mode (visible prop is provided), don't use trigger prop
const tippyProps = visible !== undefined
? { ...props, visible, interactive: true, appendTo: 'parent' }
: { ...props, trigger: 'click', interactive: true, appendTo: 'parent' };
return (
<StyledWrapper className="dropdown" transparent={transparent}>
<Tippy
@@ -11,10 +16,7 @@ const Dropdown = ({ icon, children, onCreate, placement, transparent, ...props }
animation={false}
arrow={false}
onCreate={onCreate}
interactive={true}
trigger="click"
appendTo="parent"
{...props}
{...tippyProps}
>
{icon}
</Tippy>

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { IconPlus, IconDownload, IconSettings } from '@tabler/icons';
import ToolHint from 'components/ToolHint';
const EnvironmentListContent = ({
environments,
activeEnvironmentUid,
description,
onEnvironmentSelect,
onSettingsClick,
onCreateClick,
onImportClick
}) => {
return (
<div>
{environments && environments.length > 0 ? (
<>
<div className="environment-list">
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
<span>No Environment</span>
</div>
<ToolHint
anchorSelect="[data-tooltip-content]"
place="right"
positionStrategy="fixed"
tooltipStyle={{
maxWidth: '200px',
wordWrap: 'break-word'
}}
delayShow={1000}
>
<div>
{environments.map((env) => (
<div
key={env.uid}
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'active' : ''}`}
onClick={() => onEnvironmentSelect(env)}
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}
</div>
</ToolHint>
<div className="dropdown-item configure-button">
<button onClick={onSettingsClick} id="configure-env">
<IconSettings size={16} strokeWidth={1.5} />
<span>Configure</span>
</button>
</div>
</div>
</>
) : (
<div className="empty-state">
<h3>Ready to get started?</h3>
<p>{description}</p>
<div className="space-y-2">
<button onClick={onCreateClick} id="create-env">
<IconPlus size={16} strokeWidth={1.5} />
Create
</button>
<button onClick={onImportClick} id="import-env">
<IconDownload size={16} strokeWidth={1.5} />
Import
</button>
</div>
</div>
)}
</div>
);
};
export default EnvironmentListContent;

View File

@@ -2,14 +2,230 @@ import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
background-color: ${(props) => props.theme.sidebar.badge.bg};
border-radius: 15px;
border-radius: 0.9375rem;
padding: 0.25rem 0.5rem 0.25rem 0.75rem;
user-select: none;
background-color: transparent;
border: 1px solid ${(props) => props.theme.dropdown.selectedColor};
line-height: 1rem;
.caret {
margin-left: 0.25rem;
color: rgb(140, 140, 140);
fill: rgb(140, 140, 140);
}
.env-icon {
margin-right: 0.25rem;
color: ${(props) => props.theme.dropdown.selectedColor};
}
.env-text {
color: ${(props) => props.theme.dropdown.selectedColor};
font-size: 0.875rem;
display: block;
}
.env-separator {
color: #8c8c8c;
margin: 0 0.25rem;
opacity: 0.7;
}
.env-text-inactive {
color: ${(props) => props.theme.dropdown.color};
font-size: 0.875rem;
opacity: 0.7;
}
&.no-environments {
background-color: ${(props) => props.theme.sidebar.badge.bg};
border: 1px solid transparent;
color: ${(props) => props.theme.dropdown.secondaryText};
}
}
.tippy-box {
width: ${(props) => props.width}px;
min-width: 12rem;
max-width: 650px !important;
min-height: 15.5rem;
max-height: 75vh;
font-size: 0.8125rem;
position: relative;
overflow: hidden;
}
.tippy-box .tippy-content {
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
.dropdown-item {
display: flex;
align-items: center;
padding: 0.35rem 0.6rem;
cursor: pointer;
font-size: 0.8125rem;
color: ${(props) => props.theme.dropdown.primaryText};
&:hover:not(:disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&.active {
background-color: ${(props) => props.theme.dropdown.selectedBg};
color: ${(props) => props.theme.dropdown.selectedColor};
}
&.no-environment {
color: ${(props) => props.theme.dropdown.mutedText};
}
}
}
.configure-button {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background-color: ${(props) => props.theme.dropdown.bg};
border-top: 0.0625rem solid ${(props) => props.theme.dropdown.separator};
z-index: 10;
margin: 0;
&:hover {
background-color: ${(props) => props.theme.dropdown.bg + ' !important'};
}
button {
color: ${(props) => props.theme.dropdown.primaryText};
display: flex;
align-items: center;
justify-content: center;
width: 100%;
gap: 0.5rem;
}
}
.tab-button {
color: var(--color-tab-inactive);
font-size: 0.8125rem;
.tab-content-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 0.125rem;
}
&.active {
color: ${(props) => props.theme.tabs.active.color};
border-bottom-color: ${(props) => props.theme.tabs.active.border};
}
&.inactive {
border-bottom-color: transparent;
}
}
.tab-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.environment-list {
flex: 1;
overflow-y: auto;
max-height: calc(75vh - 8rem);
padding-bottom: 2.625rem;
}
.dropdown-item-list {
max-height: 75vh;
overflow-y: scroll;
}
.empty-state {
max-width: 20rem;
margin: 0 auto;
padding: 0.35rem 0.6rem;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 12.5rem;
h3 {
color: ${(props) => props.theme.dropdown.primaryText};
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
line-height: 1.4;
}
p {
color: ${(props) => props.theme.dropdown.primaryText};
opacity: 0.75;
font-size: 0.6875rem;
line-height: 1.5;
margin-bottom: 1rem;
max-width: 11.875rem;
margin: 0 auto;
margin-bottom: 1rem;
}
.space-y-2 {
width: 100%;
align-self: stretch;
}
.space-y-2 > button {
border: 0.0625rem solid ${(props) => props.theme.dropdown.primaryText};
background: transparent;
color: ${(props) => props.theme.dropdown.primaryText};
padding: 0.5rem 1rem;
border-radius: 0.375rem;
width: 100%;
margin-bottom: 0.5rem;
font-size: 0.75rem;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
&:hover {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&:last-child {
margin-bottom: 0;
}
}
}
.no-collection-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
color: ${(props) => props.theme.dropdown.primaryText};
font-size: 0.8125rem;
line-height: 1.5;
text-align: center;
opacity: 0.75;
svg {
margin: 0 auto 1rem auto;
color: ${(props) => props.theme.dropdown.primaryText};
opacity: 0.5;
}
}
`;

View File

@@ -1,95 +1,274 @@
import React, { useRef, forwardRef, useState } from 'react';
import React, { useMemo, useState, useRef, forwardRef } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { IconWorld, IconDatabase, IconCaretDown, IconSettings, IconPlus, IconDownload } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { updateEnvironmentSettingsModalVisibility } from 'providers/ReduxStore/slices/app';
import { IconSettings, IconCaretDown, IconDatabase, IconDatabaseOff } from '@tabler/icons';
import EnvironmentSettings from '../EnvironmentSettings';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import toast from 'react-hot-toast';
import { useDispatch } from 'react-redux';
import EnvironmentListContent from './EnvironmentListContent/index';
import EnvironmentSettings from '../EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/GlobalEnvironments/EnvironmentSettings';
import CreateEnvironment from '../EnvironmentSettings/CreateEnvironment';
import ImportEnvironment from '../EnvironmentSettings/ImportEnvironment';
import CreateGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/CreateEnvironment';
import ImportGlobalEnvironment from 'components/GlobalEnvironments/EnvironmentSettings/ImportEnvironment';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
const EnvironmentSelector = ({ collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const { environments, activeEnvironmentUid } = collection;
const activeEnvironment = activeEnvironmentUid ? find(environments, (e) => e.uid === activeEnvironmentUid) : null;
const [activeTab, setActiveTab] = useState('collection');
const [showGlobalSettings, setShowGlobalSettings] = useState(false);
const [showCollectionSettings, setShowCollectionSettings] = useState(false);
const [showCreateGlobalModal, setShowCreateGlobalModal] = useState(false);
const [showImportGlobalModal, setShowImportGlobalModal] = useState(false);
const [showCreateCollectionModal, setShowCreateCollectionModal] = useState(false);
const [showImportCollectionModal, setShowImportCollectionModal] = useState(false);
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const activeGlobalEnvironment = activeGlobalEnvironmentUid
? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid)
: null;
const environments = collection?.environments || [];
const activeEnvironmentUid = collection?.activeEnvironmentUid;
const activeCollectionEnvironment = activeEnvironmentUid
? find(environments, (e) => e.uid === activeEnvironmentUid)
: null;
const tabs = [
{ id: 'collection', label: 'Collection', icon: <IconDatabase size={16} strokeWidth={1.5} /> },
{ id: 'global', label: 'Global', icon: <IconWorld size={16} strokeWidth={1.5} /> }
];
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
// Get description based on active tab
const description =
activeTab === 'collection'
? 'Create your first environment to begin working with your collection.'
: 'Create your first global environment to begin working across collections.';
// Environment selection handler
const handleEnvironmentSelect = (environment) => {
const action =
activeTab === 'collection'
? selectEnvironment(environment ? environment.uid : null, collection.uid)
: selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null });
dispatch(action)
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success('No Environments are active now');
}
dropdownTippyRef.current.hide();
})
.catch((err) => {
toast.error('An error occurred while selecting the environment');
});
};
// Settings handler
const handleSettingsClick = () => {
if (activeTab === 'collection') {
dispatch(updateEnvironmentSettingsModalVisibility(true));
setShowCollectionSettings(true);
} else {
setShowGlobalSettings(true);
}
dropdownTippyRef.current.hide();
};
// Create handler
const handleCreateClick = () => {
if (activeTab === 'collection') {
setShowCreateCollectionModal(true);
} else {
setShowCreateGlobalModal(true);
}
dropdownTippyRef.current.hide();
};
// Import handler
const handleImportClick = () => {
if (activeTab === 'collection') {
setShowImportCollectionModal(true);
} else {
setShowImportGlobalModal(true);
}
dropdownTippyRef.current.hide();
};
// Modal handlers
const handleCloseSettings = () => {
setShowGlobalSettings(false);
setShowCollectionSettings(false);
dispatch(updateEnvironmentSettingsModalVisibility(false));
};
// Calculate dropdown width based on the longest environment name.
// To prevent resizing while switching between collection and global environments.
const dropdownWidth = useMemo(() => {
const allEnvironments = [...environments, ...globalEnvironments];
if (allEnvironments.length === 0) return 0;
const maxCharLength = Math.max(...allEnvironments.map((env) => env.name?.length || 0));
// 8 pixels per character: This is a rough estimate for the average character width in most fonts
// (monospace fonts are typically 8-10px, proportional fonts vary but 8px is a safe average)
return maxCharLength * 8;
}, [environments, globalEnvironments]);
// Create icon component for dropdown trigger
const Icon = forwardRef((props, ref) => {
const hasAnyEnv = activeGlobalEnvironment || activeCollectionEnvironment;
const displayContent = hasAnyEnv ? (
<>
{activeCollectionEnvironment && (
<>
<div className="flex items-center">
<IconDatabase size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeCollectionEnvironment.name}
toolhintId={`collection-env-${activeCollectionEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeCollectionEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeCollectionEnvironment.name}</span>
</ToolHint>
</div>
{activeGlobalEnvironment && <span className="env-separator">|</span>}
</>
)}
{activeGlobalEnvironment && (
<div className="flex items-center">
<IconWorld size={14} strokeWidth={1.5} className="env-icon" />
<ToolHint
text={activeGlobalEnvironment.name}
toolhintId={`global-env-${activeGlobalEnvironment.uid}`}
place="bottom-start"
delayShow={1000}
hidden={activeGlobalEnvironment.name?.length < 7}
>
<span className="env-text max-w-24 truncate overflow-hidden">{activeGlobalEnvironment.name}</span>
</ToolHint>
</div>
)}
</>
) : (
<span className="env-text-inactive max-w-36 truncate no-wrap">No environments</span>
);
return (
<div ref={ref} className="current-environment flex items-center justify-center pl-3 pr-2 py-1 select-none">
<p className="text-nowrap truncate max-w-32">{activeEnvironment ? activeEnvironment.name : 'No Environment'}</p>
<div
ref={ref}
className={`current-environment flex align-center justify-center cursor-pointer bg-transparent ${
!hasAnyEnv ? 'no-environments' : ''
}`}
data-testid="environment-selector-trigger"
>
{displayContent}
<IconCaretDown className="caret" size={14} strokeWidth={2} />
</div>
);
});
const handleSettingsIconClick = () => {
setOpenSettingsModal(true);
dispatch(updateEnvironmentSettingsModalVisibility(true));
};
const handleModalClose = () => {
setOpenSettingsModal(false);
dispatch(updateEnvironmentSettingsModalVisibility(false));
};
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const onSelect = (environment) => {
dispatch(selectEnvironment(environment ? environment.uid : null, collection.uid))
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success(`No Environments are active now`);
}
})
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
};
return (
<StyledWrapper>
<div className="flex items-center cursor-pointer environment-selector">
<StyledWrapper width={dropdownWidth}>
<div className="environment-selector flex align-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div className="label-item font-medium">Collection Environments</div>
{environments && environments.length
? environments.map((e) => (
<div
className={`dropdown-item ${e?.uid === activeEnvironmentUid ? 'active' : ''}`}
key={e.uid}
onClick={() => {
onSelect(e);
dropdownTippyRef.current.hide();
}}
>
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
</div>
))
: null}
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onSelect(null);
}}
>
<IconDatabaseOff size={18} strokeWidth={1.5} />
<span className="ml-2">No Environment</span>
{/* Tab Headers */}
<div className="tab-header flex p-[0.75rem]">
{tabs.map((tab) => (
<button
key={tab.id}
className={`tab-button whitespace-nowrap pb-[0.375rem] border-b-[0.125rem] bg-transparent flex align-center cursor-pointer transition-all duration-200 mr-[1.25rem] ${
activeTab === tab.id ? 'active' : 'inactive'
}`}
onClick={() => setActiveTab(tab.id)}
data-testid={`env-tab-${tab.id}`}
>
<span className="tab-content-wrapper">
{tab.icon}
{tab.label}
</span>
</button>
))}
</div>
<div className="dropdown-item border-top" onClick={() => {
handleSettingsIconClick();
dropdownTippyRef.current.hide();
}}>
<div className="pr-2 text-gray-600">
<IconSettings size={18} strokeWidth={1.5} />
</div>
<span>Configure</span>
{/* Tab Content */}
<div className="tab-content">
<EnvironmentListContent
environments={activeTab === 'collection' ? environments : globalEnvironments}
activeEnvironmentUid={activeTab === 'collection' ? activeEnvironmentUid : activeGlobalEnvironmentUid}
description={description}
onEnvironmentSelect={handleEnvironmentSelect}
onSettingsClick={handleSettingsClick}
onCreateClick={handleCreateClick}
onImportClick={handleImportClick}
/>
</div>
</Dropdown>
</div>
{openSettingsModal && <EnvironmentSettings collection={collection} onClose={handleModalClose} />}
{/* Modals - Rendered outside dropdown to avoid conflicts */}
{showGlobalSettings && (
<GlobalEnvironmentSettings
globalEnvironments={globalEnvironments}
collection={collection}
activeGlobalEnvironmentUid={activeGlobalEnvironmentUid}
onClose={handleCloseSettings}
/>
)}
{showCollectionSettings && <EnvironmentSettings collection={collection} onClose={handleCloseSettings} />}
{showCreateGlobalModal && (
<CreateGlobalEnvironment
onClose={() => setShowCreateGlobalModal(false)}
onEnvironmentCreated={() => {
setShowGlobalSettings(true);
}}
/>
)}
{showImportGlobalModal && (
<ImportGlobalEnvironment
onClose={() => setShowImportGlobalModal(false)}
onEnvironmentCreated={() => {
setShowGlobalSettings(true);
}}
/>
)}
{showCreateCollectionModal && (
<CreateEnvironment
collection={collection}
onClose={() => setShowCreateCollectionModal(false)}
onEnvironmentCreated={() => {
setShowCollectionSettings(true);
}}
/>
)}
{showImportCollectionModal && (
<ImportEnvironment
collection={collection}
onClose={() => setShowImportCollectionModal(false)}
onEnvironmentCreated={() => {
setShowCollectionSettings(true);
}}
/>
)}
</StyledWrapper>
);
};

View File

@@ -8,7 +8,7 @@ import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ collection, onClose }) => {
const CreateEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const inputRef = useRef();
@@ -37,6 +37,10 @@ const CreateEnvironment = ({ collection, onClose }) => {
.then(() => {
toast.success('Environment created in collection');
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch(() => toast.error('An error occurred while creating the environment'));
}

View File

@@ -5,7 +5,7 @@ import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCh
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
@@ -173,7 +173,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
return (
<StyledWrapper className="w-full mt-6 mb-6">
<div className="h-[50vh] overflow-y-auto w-full">
<table>
<table className="environment-variables">
<thead>
<tr>
<td className="text-center">Enabled</td>
@@ -214,7 +214,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
</td>
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
@@ -253,6 +253,8 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
id="add-variable"
data-testid="add-variable"
>
+ Add Variable
</button>
@@ -260,15 +262,15 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
</div>
<div className="flex items-center">
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit}>
<button type="submit" className="submit btn btn-sm btn-secondary mt-2 flex items-center" onClick={formik.handleSubmit} data-testid="save-env">
<IconDeviceFloppy size={16} strokeWidth={1.5} className="mr-1" />
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset}>
<button type="submit" className="ml-2 px-1 submit btn btn-sm btn-close mt-2 flex items-center" onClick={handleReset} data-testid="reset-env">
<IconRefresh size={16} strokeWidth={1.5} className="mr-1" />
Reset
</button>
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate}>
<button type="submit" className="submit btn btn-sm btn-close mt-2 flex items-center" onClick={onActivate} data-testid="activate-env">
<IconCircleCheck size={16} strokeWidth={1.5} className="mr-1" />
Activate
</button>

View File

@@ -8,7 +8,7 @@ import { importEnvironment } from 'providers/ReduxStore/slices/collections/actio
import { toastError } from 'utils/common/error';
import { IconDatabaseImport } from '@tabler/icons';
const ImportEnvironment = ({ collection, onClose }) => {
const ImportEnvironment = ({ collection, onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
@@ -36,17 +36,22 @@ const ImportEnvironment = ({ collection, onClose }) => {
})
.then(() => {
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
return (
<Portal>
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<Modal size="sm" title="Import Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-environment-modal">
<button
type="button"
onClick={handleImportPostmanEnvironment}
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
data-testid="import-postman-environment"
>
<IconDatabaseImport size={64} />
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>

View File

@@ -56,9 +56,8 @@ const EnvironmentSettings = ({ collection, onClose }) => {
) : tab === 'import' ? (
<ImportEnvironment collection={collection} onClose={() => setTab('default')} />
) : (
<></>
<DefaultTab setTab={setTab} />
)}
<DefaultTab setTab={setTab} />
</Modal>
</StyledWrapper>
);

View File

@@ -17,7 +17,7 @@ import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -48,15 +48,7 @@ const Auth = ({ collection, folder }) => {
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const getTreePathFromCollectionToFolder = (collection, _folder) => {
let path = [];
let item = findItemInCollection(collection, _folder?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
@@ -69,7 +61,7 @@ const Auth = ({ collection, folder }) => {
};
// Get path from collection to current folder
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
// Check parent folders to find closest auth configuration
// Skip the last item which is the current folder

View File

@@ -1,18 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.current-environment {
}
.environment-active {
padding: 0.3rem 0.4rem;
color: ${(props) => props.theme.colors.text.yellow};
border: solid 1px ${(props) => props.theme.colors.text.yellow} !important;
}
.environment-selector {
.active: {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;
export default Wrapper;

View File

@@ -1,100 +0,0 @@
import React, { useRef, forwardRef, useState } from 'react';
import find from 'lodash/find';
import Dropdown from 'components/Dropdown';
import { IconSettings, IconWorld, IconDatabase, IconDatabaseOff, IconCheck } from '@tabler/icons';
import EnvironmentSettings from '../EnvironmentSettings';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import ToolHint from 'components/ToolHint/index';
const EnvironmentSelector = () => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const activeEnvironment = activeGlobalEnvironmentUid ? find(globalEnvironments, (e) => e.uid === activeGlobalEnvironmentUid) : null;
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className={`current-environment flex flex-row gap-1 rounded-xl text-xs cursor-pointer items-center justify-center select-none ${activeGlobalEnvironmentUid? 'environment-active': ''}`}>
<ToolHint text="Global Environments" toolhintId="GlobalEnvironmentsToolhintId" className='flex flex-row'>
<IconWorld className="globe" size={16} strokeWidth={1.5} />
{
activeEnvironment ? <div className='text-nowrap truncate max-w-32'>{activeEnvironment?.name}</div> : null
}
</ToolHint>
</div>
);
});
const handleSettingsIconClick = () => {
setOpenSettingsModal(true);
};
const handleModalClose = () => {
setOpenSettingsModal(false);
};
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const onSelect = (environment) => {
dispatch(selectGlobalEnvironment({ environmentUid: environment ? environment.uid : null }))
.then(() => {
if (environment) {
toast.success(`Environment changed to ${environment.name}`);
} else {
toast.success(`No Environments are active now`);
}
})
.catch((err) => console.log(err) && toast.error('An error occurred while selecting the environment'));
};
return (
<StyledWrapper>
<div className="flex items-center cursor-pointer environment-selector mr-3">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end" transparent={true}>
<div className="label-item font-medium">Global Environments</div>
{globalEnvironments && globalEnvironments.length
? globalEnvironments.map((e) => (
<div
className={`dropdown-item ${e?.uid === activeGlobalEnvironmentUid ? 'active' : ''}`}
key={e.uid}
onClick={() => {
onSelect(e);
dropdownTippyRef.current.hide();
}}
>
<IconDatabase size={18} strokeWidth={1.5} /> <span className="ml-2 break-all">{e.name}</span>
</div>
))
: null}
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onSelect(null);
}}
>
<IconDatabaseOff size={18} strokeWidth={1.5} />
<span className="ml-2">No Environment</span>
</div>
<div className="dropdown-item border-top" onClick={() => {
handleSettingsIconClick();
dropdownTippyRef.current.hide();
}}>
<div className="pr-2 text-gray-600">
<IconSettings size={18} strokeWidth={1.5} />
</div>
<span>Configure</span>
</div>
</Dropdown>
</div>
{openSettingsModal && <EnvironmentSettings globalEnvironments={globalEnvironments} activeGlobalEnvironmentUid={activeGlobalEnvironmentUid} onClose={handleModalClose} />}
</StyledWrapper>
);
};
export default EnvironmentSelector;

View File

@@ -8,7 +8,7 @@ import Modal from 'components/Modal';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { validateName, validateNameError } from 'utils/common/regex';
const CreateEnvironment = ({ onClose }) => {
const CreateEnvironment = ({ onClose, onEnvironmentCreated }) => {
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const validateEnvironmentName = (name) => {
@@ -39,6 +39,10 @@ const CreateEnvironment = ({ onClose }) => {
.then(() => {
toast.success('Global environment created!');
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch(() => toast.error('An error occurred while creating the environment'));
}

View File

@@ -1,9 +1,9 @@
import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { useDispatch, useSelector } from 'react-redux';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
@@ -12,11 +12,18 @@ import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables }) => {
const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentVariables, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector(state => state.globalEnvironments);
let _collection = cloneDeep(collection);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
const formik = useFormik({
enableReinitialize: true,
@@ -34,7 +41,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.string().trim().nullable()
value: Yup.mixed().nullable()
})
),
onSubmit: (values) => {
@@ -93,7 +100,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
useEffect(() => {
if (formik.dirty) {
// Smooth scrolling to the changed parameter is temporarily disabled
// Smooth scrolling to the changed parameter is temporarily disabled
// due to UX issues when editing the first row in a long list of environment variables.
// addButtonRef.current?.scrollIntoView({ behavior: 'smooth' });
}
@@ -129,7 +136,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
/>
</td>
<td>
<div className="flex items-center">
<div className="flex items-center" data-testid={`env-var-name-${index}`}>
<input
type="text"
autoComplete="off"
@@ -145,16 +152,32 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
<ErrorMessage name={`${index}.name`} />
</div>
</td>
<td className="flex flex-row flex-nowrap">
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
<td className="flex flex-row flex-nowrap items-center">
<div className="overflow-hidden grow w-full relative" data-testid={`env-var-value-${index}`}>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${index}.value`, newValue, true)}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle
id={`${variable.name}-disabled-info-icon`}
className="text-muted"
size={16}
/>
<Tooltip
anchorId={`${variable.name}-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">
<input
@@ -179,6 +202,7 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
ref={addButtonRef}
className="btn-add-param text-link pr-2 py-3 mt-2 select-none"
onClick={addVariable}
data-testid="add-variable"
>
+ Add Variable
</button>
@@ -186,10 +210,10 @@ const EnvironmentVariables = ({ environment, setIsModified, originalEnvironmentV
</div>
<div>
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit}>
<button type="submit" className="submit btn btn-md btn-secondary mt-2" onClick={formik.handleSubmit} data-testid="save-env">
Save
</button>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset}>
<button type="submit" className="ml-2 px-1 submit btn btn-md btn-secondary mt-2" onClick={handleReset} data-testid="reset-env">
Reset
</button>
</div>

View File

@@ -5,7 +5,7 @@ import DeleteEnvironment from '../../DeleteEnvironment';
import RenameEnvironment from '../../RenameEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
const EnvironmentDetails = ({ environment, setIsModified }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [openEditModal, setOpenEditModal] = useState(false);
const [openDeleteModal, setOpenDeleteModal] = useState(false);
const [openCopyModal, setOpenCopyModal] = useState(false);
@@ -37,7 +37,7 @@ const EnvironmentDetails = ({ environment, setIsModified }) => {
</div>
<div>
<EnvironmentVariables environment={environment} setIsModified={setIsModified} />
<EnvironmentVariables environment={environment} setIsModified={setIsModified} collection={collection} />
</div>
</div>
);

View File

@@ -10,7 +10,7 @@ import ImportEnvironment from '../ImportEnvironment';
import { isEqual } from 'lodash';
import ToolHint from 'components/ToolHint/index';
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified }) => {
const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironment, setSelectedEnvironment, isModified, setIsModified, collection }) => {
const [openCreateModal, setOpenCreateModal] = useState(false);
const [openImportModal, setOpenImportModal] = useState(false);
const [openManageSecretsModal, setOpenManageSecretsModal] = useState(false);
@@ -143,6 +143,7 @@ const EnvironmentList = ({ environments, activeEnvironmentUid, selectedEnvironme
environment={selectedEnvironment}
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
/>
</div>
</StyledWrapper>

View File

@@ -9,7 +9,7 @@ import { IconDatabaseImport } from '@tabler/icons';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { uuid } from 'utils/common/index';
const ImportEnvironment = ({ onClose }) => {
const ImportEnvironment = ({ onClose, onEnvironmentCreated }) => {
const dispatch = useDispatch();
const handleImportPostmanEnvironment = () => {
@@ -37,17 +37,22 @@ const ImportEnvironment = ({ onClose }) => {
})
.then(() => {
onClose();
// Call the callback if provided
if (onEnvironmentCreated) {
onEnvironmentCreated();
}
})
.catch((err) => toastError(err, 'Postman Import environment failed'));
};
return (
<Portal>
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose}>
<Modal size="sm" title="Import Global Environment" hideFooter={true} handleConfirm={onClose} handleCancel={onClose} dataTestId="import-global-environment-modal">
<button
type="button"
onClick={handleImportPostmanEnvironment}
className="flex justify-center flex-col items-center w-full dark:bg-zinc-700 rounded-lg border-2 border-dashed border-zinc-300 dark:border-zinc-400 p-12 text-center hover:border-zinc-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2"
data-testid="import-postman-global-environment"
>
<IconDatabaseImport size={64} />
<span className="mt-2 block text-sm font-semibold">Import your Postman environments</span>

View File

@@ -39,7 +39,7 @@ const DefaultTab = ({ setTab }) => {
);
};
const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, onClose }) => {
const EnvironmentSettings = ({ globalEnvironments, collection, activeGlobalEnvironmentUid, onClose }) => {
const [isModified, setIsModified] = useState(false);
const environments = globalEnvironments;
const [selectedEnvironment, setSelectedEnvironment] = useState(null);
@@ -53,9 +53,8 @@ const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, o
) : tab === 'import' ? (
<ImportEnvironment onClose={() => setTab('default')} />
) : (
<></>
<DefaultTab setTab={setTab} />
)}
<DefaultTab setTab={setTab} />
</Modal>
</StyledWrapper>
);
@@ -70,6 +69,7 @@ const EnvironmentSettings = ({ globalEnvironments, activeGlobalEnvironmentUid, o
setSelectedEnvironment={setSelectedEnvironment}
isModified={isModified}
setIsModified={setIsModified}
collection={collection}
/>
</Modal>
);

View File

@@ -0,0 +1,361 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
/* Screen reader only content */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.command-k-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
z-index: 20;
background-color: transparent;
&:before {
content: '';
height: 100%;
width: 100%;
left: 0;
opacity: ${(props) => props.theme.modal.backdrop.opacity};
top: 0;
background: black;
position: fixed;
}
animation: fade-in 0.1s forwards cubic-bezier(0.19, 1, 0.22, 1);
}
.command-k-modal {
background: ${(props) => props.theme.modal.body.bg};
border: 1px solid ${(props) => props.theme.modal.input.border};
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
width: 90%;
max-width: 600px;
max-height: 70vh;
display: flex;
flex-direction: column;
overflow: hidden;
margin: 80px auto;
animation: fade-and-slide-in-from-top 0.3s forwards cubic-bezier(0.19, 1, 0.22, 1);
will-change: opacity, transform;
}
.command-k-header {
padding: 12px;
border-bottom: 1px solid ${(props) => props.theme.modal.input.border};
background: ${(props) => props.theme.modal.title.bg};
}
.search-input-container {
position: relative;
display: flex;
align-items: center;
width: 100%;
padding: 8px 12px;
border: 1px solid ${(props) => props.theme.modal.input.border};
border-radius: 6px;
background: ${(props) => props.theme.modal.input.bg};
transition: all 0.2s ease;
&:focus-within {
border-color: ${(props) => props.theme.colors.text.muted};
box-shadow: 0 0 0 1px ${(props) => props.theme.colors.text.muted}40;
}
.search-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
margin-right: 8px;
flex-shrink: 0;
}
.clear-button {
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
margin-left: 8px;
border-radius: 4px;
flex-shrink: 0;
&:hover {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'};
}
}
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.text};
font-size: 13px;
width: 100%;
padding: 0;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.7;
}
}
.command-k-results {
flex: 1;
overflow-y: auto;
max-height: 400px;
background: ${(props) => props.theme.modal.body.bg};
scrollbar-width: thin;
padding: 4px;
scroll-behavior: smooth;
/* Webkit scrollbar styling */
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.2)'};
border-radius: 4px;
&:hover {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.3)'};
}
}
}
.result-item {
display: flex;
align-items: center;
padding: 8px 12px;
gap: 8px;
cursor: pointer;
border-left: 2px solid transparent;
&:hover {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
}
&.selected {
background: ${(props) => `${props.theme.colors.text.yellow}15`};
border-left: 2px solid ${(props) => props.theme.colors.text.yellow};
}
}
.result-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
flex-shrink: 0;
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
}
.result-content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.result-info {
flex: 1;
min-width: 0;
margin-right: 8px;
}
.result-badges {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.result-name {
font-size: 13px;
margin-bottom: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: ${(props) => props.theme.text};
letter-spacing: 0.2px;
}
.result-path {
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.1px;
}
.method-badge {
font-size: 11px;
font-weight: 500;
padding: 3px 8px;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
min-width: 55px;
text-align: center;
&.get {
color: #2ecc71;
background: rgba(46, 204, 113, 0.1);
}
&.post {
color: #3498db;
background: rgba(52, 152, 219, 0.1);
}
&.put {
color: #e67e22;
background: rgba(230, 126, 34, 0.1);
}
&.delete {
color: #e74c3c;
background: rgba(231, 76, 60, 0.1);
}
&.patch {
color: #9b59b6;
background: rgba(155, 89, 182, 0.1);
}
&.head {
color: #2980b9;
background: rgba(41, 128, 185, 0.1);
}
&.options {
color: #f1c40f;
background: rgba(241, 196, 15, 0.1);
}
&.unary {
color: #27ae60;
background: rgba(39, 174, 96, 0.12);
font-weight: 600;
}
&.client-streaming {
color: #2980b9;
background: rgba(41, 128, 185, 0.12);
font-weight: 600;
}
&.server-streaming {
color: #f39c12;
background: rgba(243, 156, 18, 0.12);
font-weight: 600;
}
&.bidirectional-streaming,
&.bidi-streaming {
color: #8e44ad;
background: rgba(142, 68, 173, 0.12);
font-weight: 600;
}
}
.result-type {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 0.3px;
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)'};
opacity: 0.8;
flex-shrink: 0;
}
.result-item[data-type="documentation"] {
.result-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
}
.result-path {
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: 0.1px;
opacity: 0.8;
}
&:hover:not(.selected) {
background: ${(props) => props.theme.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
}
}
.no-results,
.empty-state {
padding: 24px 16px;
text-align: center;
color: ${(props) => props.theme.colors.text.muted};
font-size: 13px;
}
.command-k-footer {
padding: 8px 12px;
border-top: 1px solid ${(props) => props.theme.modal.input.border};
background: ${(props) => props.theme.colors.surface};
}
.keyboard-hints {
display: flex;
justify-content: center;
gap: 24px;
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
letter-spacing: 0.2px;
span {
display: flex;
align-items: center;
gap: 6px;
.hint-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.8;
}
.hint-icon + .hint-icon {
margin-left: -8px;
}
.keycap {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border: 1px solid ${(props) => props.theme.modal.input.border};
border-radius: 4px;
background: ${(props) =>
props.theme.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'};
font-size: 11px;
font-weight: 500;
font-family: inherit;
line-height: 1;
color: ${(props) => props.theme.text};
}
}
}
.highlight {
background: ${(props) => `${props.theme.colors.text.yellow}30`};
border-radius: 2px;
padding: 0 2px;
margin: 0 -1px;
font-weight: 500;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fade-and-slide-in-from-top {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,32 @@
export const SEARCH_TYPES = {
DOCUMENTATION: 'documentation',
COLLECTION: 'collection',
FOLDER: 'folder',
REQUEST: 'request'
};
export const MATCH_TYPES = {
COLLECTION: 'collection',
FOLDER: 'folder',
REQUEST: 'request',
URL: 'url',
PATH: 'path',
DOCUMENTATION: 'documentation'
};
export const SEARCH_CONFIG = {
MAX_DEPTH: 20,
FOCUS_DELAY: 100,
SCROLL_BEHAVIOR: 'smooth',
SCROLL_BLOCK: 'nearest',
DEBOUNCE_DELAY: 300
};
export const DOCUMENTATION_RESULT = {
type: SEARCH_TYPES.DOCUMENTATION,
item: { id: 'docs', name: 'Bruno Documentation' },
name: 'Bruno Documentation',
path: '/',
description: 'Browse the official Bruno documentation',
matchType: MATCH_TYPES.DOCUMENTATION
};

View File

@@ -0,0 +1,516 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconSearch,
IconX,
IconFolder,
IconBox,
IconFileText,
IconBook
} from '@tabler/icons';
import { flattenItems, isItemARequest, isItemAFolder, findParentItemInCollection } from 'utils/collections';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { normalizeQuery, isValidQuery, highlightText, sortResults, getTypeLabel, getItemPath } from './utils/searchUtils';
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG, DOCUMENTATION_RESULT } from './constants';
import StyledWrapper from './StyledWrapper';
const GlobalSearchModal = ({ isOpen, onClose }) => {
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const [results, setResults] = useState([]);
const inputRef = useRef(null);
const resultsRef = useRef(null);
const debounceTimeoutRef = useRef(null);
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
const createCollectionResults = () => {
const collectionResults = collections.map(collection => ({
type: SEARCH_TYPES.COLLECTION,
item: collection,
name: collection.name,
path: collection.name,
matchType: MATCH_TYPES.COLLECTION,
collectionUid: collection.uid
}));
collectionResults.sort((a, b) => a.name.localeCompare(b.name));
return [DOCUMENTATION_RESULT, ...collectionResults];
};
const searchInCollections = (searchTerms, enablePathMatch) => {
const results = [];
// Check for documentation match
const queryLower = searchTerms.join(' ');
if (['documentation', 'docs', 'bruno docs'].some(term => term.includes(queryLower))) {
results.push(DOCUMENTATION_RESULT);
}
collections.forEach(collection => {
// Search collection name
if (searchTerms.every(term => collection.name.toLowerCase().includes(term))) {
results.push({
type: SEARCH_TYPES.COLLECTION,
item: collection,
name: collection.name,
path: collection.name,
matchType: MATCH_TYPES.COLLECTION,
collectionUid: collection.uid
});
}
// Search collection items
const flattenedItems = flattenItems(collection.items);
flattenedItems.forEach(item => {
const itemPath = getItemPath(item, collection, findParentItemInCollection);
const itemPathLower = itemPath.toLowerCase();
if (isItemARequest(item)) {
// add an optional check for the item name to prevent a crash if it doesnt exist.
const nameMatch = searchTerms.every(term => (item.name || '').toLowerCase().includes(term));
const urlMatch = searchTerms.every(term => (item.request?.url || '').toLowerCase().includes(term));
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
if (nameMatch || urlMatch || pathMatch) {
// Check if this is a gRPC request and get the method type
const isGrpcRequest = item.request?.type === 'grpc';
let method = item.request?.method || '';
if (isGrpcRequest) {
// For gRPC requests, use the methodType
const methodType = item.request?.methodType || 'UNARY';
method = methodType.toLowerCase().replace(/[_]/g, '-');
}
results.push({
type: SEARCH_TYPES.REQUEST,
item,
name: item.name,
path: itemPath,
matchType: nameMatch ? MATCH_TYPES.REQUEST : urlMatch ? MATCH_TYPES.URL : MATCH_TYPES.PATH,
method,
collectionUid: collection.uid
});
}
} else if (isItemAFolder(item)) {
const nameMatch = searchTerms.every(term => item.name.toLowerCase().includes(term));
const pathMatch = enablePathMatch && searchTerms.every(term => itemPathLower.includes(term));
if (nameMatch || pathMatch) {
results.push({
type: SEARCH_TYPES.FOLDER,
item,
name: item.name,
path: itemPath,
matchType: nameMatch ? MATCH_TYPES.FOLDER : MATCH_TYPES.PATH,
collectionUid: collection.uid
});
}
}
});
});
return results;
};
const performSearch = (searchQuery) => {
const normalizedQuery = normalizeQuery(searchQuery);
if (!normalizedQuery) {
setResults(createCollectionResults());
return;
}
if (!isValidQuery(normalizedQuery)) {
setResults([]);
return;
}
const searchTerms = normalizedQuery.toLowerCase().split(/[\s\/]+/).filter(Boolean);
if (!searchTerms.length) {
setResults([]);
return;
}
const enablePathMatch = normalizedQuery.includes('/');
const searchResults = searchInCollections(searchTerms, enablePathMatch);
const sortedResults = sortResults(searchResults);
setResults(sortedResults);
setSelectedIndex(0);
};
const debouncedSearch = useCallback((searchQuery) => {
// Clear existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Set new timeout
debounceTimeoutRef.current = setTimeout(() => {
performSearch(searchQuery);
}, SEARCH_CONFIG.DEBOUNCE_DELAY);
}, [collections]); // Depend on collections to recreate when they change
const expandItemPath = (result) => {
const collection = collections.find(c => c.uid === result.collectionUid);
if (!collection) return;
ensureCollectionIsMounted(collection);
if (collection.collapsed) {
dispatch(toggleCollection(collection.uid));
}
let currentItem = result.type === SEARCH_TYPES.FOLDER
? result.item
: findParentItemInCollection(collection, result.item.uid);
while (currentItem?.type === 'folder') {
if (currentItem.collapsed) {
dispatch(toggleCollectionItem({ collectionUid: collection.uid, itemUid: currentItem.uid }));
}
currentItem = findParentItemInCollection(collection, currentItem.uid);
}
};
const ensureCollectionIsMounted = (collection) => {
if (!collection || collection.mountStatus === 'mounted') return;
dispatch(mountCollection({
collectionUid: collection.uid,
collectionPathname: collection.pathname,
brunoConfig: collection.brunoConfig
}));
};
const handleKeyNavigation = (e) => {
const handlers = {
ArrowDown: () => {
e.preventDefault();
setSelectedIndex(prev => prev < results.length - 1 ? prev + 1 : 0);
},
ArrowUp: () => {
e.preventDefault();
setSelectedIndex(prev => prev > 0 ? prev - 1 : results.length - 1);
},
Enter: () => {
e.preventDefault();
if (results[selectedIndex]) {
handleResultSelection(results[selectedIndex]);
}
},
Escape: () => {
e.preventDefault();
onClose();
},
PageDown: () => {
e.preventDefault();
setSelectedIndex(prev => Math.min(prev + 5, results.length - 1));
},
PageUp: () => {
e.preventDefault();
setSelectedIndex(prev => Math.max(prev - 5, 0));
},
Home: () => {
e.preventDefault();
setSelectedIndex(0);
},
End: () => {
e.preventDefault();
setSelectedIndex(results.length - 1);
}
};
const handler = handlers[e.key];
if (handler) handler();
};
const handleResultSelection = (result) => {
const targetCollection = collections.find(c => c.uid === result.collectionUid);
ensureCollectionIsMounted(targetCollection);
if (result.type === SEARCH_TYPES.DOCUMENTATION) {
window.open('https://docs.usebruno.com/', '_blank');
onClose();
return;
}
expandItemPath(result);
if (result.type === SEARCH_TYPES.REQUEST) {
dispatch(hideHomePage());
const existingTab = tabs.find(tab => tab.uid === result.item.uid);
if (existingTab) {
dispatch(focusTab({ uid: result.item.uid }));
} else {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
requestPaneTab: getDefaultRequestPaneTab(result.item),
type: 'request',
}));
}
} else if (result.type === SEARCH_TYPES.FOLDER) {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
type: 'folder-settings',
}));
} else if (result.type === SEARCH_TYPES.COLLECTION) {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
type: 'collection-settings',
}));
}
onClose();
};
const handleQueryChange = (e) => {
const newQuery = e.target.value;
setQuery(newQuery);
if (newQuery.trim()) {
debouncedSearch(newQuery);
} else {
// For empty queries, search immediately to show collections
performSearch(newQuery);
}
};
const clearSearch = () => {
// Clear any pending debounced search
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
setQuery('');
setResults([]);
};
// Initialize modal when opened
useEffect(() => {
if (isOpen) {
const timeoutId = setTimeout(() => inputRef.current?.focus(), SEARCH_CONFIG.FOCUS_DELAY);
setQuery('');
performSearch('');
setSelectedIndex(0);
return () => clearTimeout(timeoutId);
} else {
// Clear any pending debounced search when modal closes
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
}
}, [isOpen]);
// Auto-scroll selected item into view
useEffect(() => {
if (resultsRef.current && results.length > 0) {
const selectedElement = resultsRef.current.children[selectedIndex];
selectedElement?.scrollIntoView({
behavior: SEARCH_CONFIG.SCROLL_BEHAVIOR,
block: SEARCH_CONFIG.SCROLL_BLOCK
});
}
}, [selectedIndex, results]);
// Cleanup debounce timeout on unmount or modal close
useEffect(() => {
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, []);
const getResultIcon = (type) => {
const iconMap = {
[SEARCH_TYPES.DOCUMENTATION]: IconBook,
[SEARCH_TYPES.COLLECTION]: IconBox,
[SEARCH_TYPES.FOLDER]: IconFolder,
[SEARCH_TYPES.REQUEST]: IconFileText
};
const IconComponent = iconMap[type] || IconFileText;
return <IconComponent size={18} stroke={1.5} />;
};
if (!isOpen) return null;
return (
<StyledWrapper>
<div
className="command-k-overlay"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-labelledby="search-modal-title"
aria-describedby="search-modal-description"
>
<div className="command-k-modal" onClick={(e) => e.stopPropagation()}>
<h1 id="search-modal-title" className="sr-only">Global Search</h1>
<p id="search-modal-description" className="sr-only">
Search through collections, requests, folders, and documentation. Use arrow keys to navigate results and Enter to select.
</p>
<div aria-live="polite" aria-atomic="true" className="sr-only">
{results.length > 0 && query
? `${results.length} result${results.length === 1 ? '' : 's'} found`
: query && results.length === 0
? 'No results found'
: ''
}
</div>
<div className="command-k-header">
<div className="search-input-container">
<IconSearch size={20} className="search-icon" aria-hidden="true" />
<input
ref={inputRef}
type="text"
placeholder="Search collections, requests, or documentation..."
value={query}
onChange={handleQueryChange}
onKeyDown={handleKeyNavigation}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
aria-label="Search collections, requests, or documentation"
aria-expanded={results.length > 0}
aria-controls="search-results"
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
role="combobox"
aria-autocomplete="list"
/>
{query && (
<button
onClick={clearSearch}
className="clear-button"
aria-label="Clear search query"
type="button"
>
<IconX size={16} aria-hidden="true" />
</button>
)}
</div>
</div>
<div
className="command-k-results"
ref={resultsRef}
id="search-results"
role="listbox"
aria-label="Search results"
>
{results.length === 0 && query ? (
<div className="no-results">
<p>
No results found for "{query}".
<br />
<span className="block mt-2">
The item might not exist yet, or its collection isnt mounted. Press <strong>Enter</strong> here (or open it from the sidebar) to mount the collection automatically.
</span>
</p>
</div>
) : results.length === 0 ? (
<div className="empty-state">
<p>
No collections are currently mounted or visible.
<br />
<span className="block mt-2">
Mount a collection via the sidebar or this search modal, then try again.
</span>
</p>
</div>
) : (
results.map((result, index) => {
const isSelected = index === selectedIndex;
const typeLabel = getTypeLabel(result.type);
return (
<div
key={`${result.type}-${result.item.id || result.item.uid}-${index}`}
id={`search-result-${index}`}
className={`result-item ${isSelected ? 'selected' : ''}`}
onClick={() => handleResultSelection(result)}
data-selected={isSelected}
data-type={result.type}
role="option"
aria-selected={isSelected}
aria-label={`${result.name}, ${typeLabel || result.type}${result.method ? `, ${result.method}` : ''}`}
tabIndex={-1}
>
<div className="result-icon">
{getResultIcon(result.type)}
</div>
<div className="result-content">
<div className="result-info">
<div className="result-name">
{highlightText(result.name, query)}
</div>
<div className="result-path">
{result.type === SEARCH_TYPES.DOCUMENTATION
? result.description
: result.type === SEARCH_TYPES.REQUEST
? highlightText(result.item.request?.url || '', query)
: highlightText(result.path, query)}
</div>
</div>
<div className="result-badges">
{result.type === SEARCH_TYPES.REQUEST && result.method && (
<span
className={`method-badge ${result.method.toLowerCase()}`}
aria-label={`HTTP method ${result.method.toUpperCase().replace(/-/g, ' ')}`}
>
{result.method.toUpperCase().replace(/-/g, ' ')}
</span>
)}
{typeLabel && (
<div className="result-type" aria-label={`Item type ${typeLabel}`}>
{typeLabel}
</div>
)}
</div>
</div>
</div>
);
})
)}
</div>
<div className="command-k-footer">
<div className="keyboard-hints" role="region" aria-label="Keyboard shortcuts">
<span aria-label="Use up and down arrows to navigate">
<span className="keycap" aria-hidden="true"></span>
<span className="keycap" aria-hidden="true"></span>
<span className="hint-label">to navigate</span>
</span>
<span aria-label="Press Enter to select">
<span className="keycap" aria-hidden="true"></span>
<span className="hint-label">to select</span>
</span>
<span aria-label="Press Escape to close">
<span className="keycap" aria-hidden="true">esc</span>
<span className="hint-label">to close</span>
</span>
</div>
</div>
</div>
</div>
</StyledWrapper>
);
};
export default GlobalSearchModal;

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { SEARCH_TYPES, MATCH_TYPES, SEARCH_CONFIG } from '../constants';
export const normalizeQuery = (searchQuery) => {
return searchQuery.trim().replace(/\/+/g, '/');
};
export const isValidQuery = (normalizedQuery) => {
return normalizedQuery &&
normalizedQuery !== '/' &&
!(normalizedQuery.length === 1 && !normalizedQuery.match(/[a-zA-Z0-9]/));
};
export const highlightText = (text, searchQuery) => {
if (!searchQuery) return text;
try {
const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`(${escapedQuery})`, 'gi');
return text.split(regex).map((part, i) =>
regex.test(part) ? (
<span key={i} className="highlight">{part}</span>
) : part
);
} catch {
return text;
}
};
export const sortResults = (results) => {
return results.sort((a, b) => {
// Documentation always first
if (a.type === SEARCH_TYPES.DOCUMENTATION) return -1;
if (b.type === SEARCH_TYPES.DOCUMENTATION) return 1;
// Sort by match type priority
const matchTypeOrder = {
[MATCH_TYPES.COLLECTION]: 0,
[MATCH_TYPES.FOLDER]: 1,
[MATCH_TYPES.REQUEST]: 2,
[MATCH_TYPES.URL]: 3,
[MATCH_TYPES.PATH]: 4
};
const aMatchType = matchTypeOrder[a.matchType] ?? 5;
const bMatchType = matchTypeOrder[b.matchType] ?? 5;
if (aMatchType !== bMatchType) return aMatchType - bMatchType;
// Sort by type priority
const typeOrder = {
[SEARCH_TYPES.COLLECTION]: 0,
[SEARCH_TYPES.FOLDER]: 1,
[SEARCH_TYPES.REQUEST]: 2
};
const aType = typeOrder[a.type] ?? 3;
const bType = typeOrder[b.type] ?? 3;
if (aType !== bType) return aType - bType;
// Finally sort alphabetically
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
});
};
export const getTypeLabel = (type) => {
const baseLabels = {
[SEARCH_TYPES.DOCUMENTATION]: 'Documentation',
[SEARCH_TYPES.COLLECTION]: 'Collection',
[SEARCH_TYPES.FOLDER]: 'Folder'
};
return baseLabels[type] || '';
};
export const getItemPath = (item, collection, findParentItemInCollection) => {
const pathParts = [];
let currentItem = item;
let depth = 0;
const maxDepth = SEARCH_CONFIG.MAX_DEPTH;
while (currentItem && depth < maxDepth) {
pathParts.unshift(currentItem.name);
const parent = findParentItemInCollection(collection, currentItem.uid);
if (parent) {
currentItem = parent;
depth++;
} else {
break;
}
}
pathParts.unshift(collection.name);
return pathParts.join('/');
};

View File

@@ -90,4 +90,4 @@ export const IconGrpcBidiStreaming = ({ size = 18, strokeWidth = 1.5, className
<path d="M6 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
<path d="M10 13l-3 3l3 3" stroke="#F97316" strokeWidth={strokeWidth} />
</svg>
);
);

View File

@@ -0,0 +1,28 @@
import React from 'react';
const IconSidebarToggle = ({ collapsed = false, size = 16, strokeWidth = 1.5, className = '', ...rest }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
strokeWidth={strokeWidth}
stroke="currentColor"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
className={`icon icon-tabler icons-tabler-outline icon-tabler-layout-sidebar ${className}`}
{...rest}
>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z" />
<path d="M9 4l0 16" />
{!collapsed && (
<rect x="4.6" y="4.6" width="4.8" height="14.8" rx="0.8" fill="currentColor" />
)}
</svg>
);
};
export default IconSidebarToggle;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { IconChevronDown, IconX } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import Dropdown from 'components/Dropdown';
const InheritableSettingsInput = ({
id,
label,
value,
description,
onKeyDown,
isInherited,
onDropdownSelect,
onValueChange,
onCustomValueReset
}) => {
const { theme } = useTheme();
return (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium text-gray-900 dark:text-gray-100" htmlFor={id}>
{label}
</label>
{description && (
<p className="text-xs text-gray-700 dark:text-gray-400">
{description}
</p>
)}
</div>
<div className="flex items-center justify-end">
{isInherited ? (
<Dropdown
icon={(
<button
type="button"
className="px-2 py-1 text-xs rounded-sm outline-none transition-colors duration-100 w-24 h-8 flex items-center justify-between"
style={{
backgroundColor: theme.modal.input.bg,
border: `1px solid ${theme.modal.input.border}`,
color: theme.modal.input.text
}}
>
<span>Inherit</span>
<IconChevronDown size={12} />
</button>
)}
>
<div className="dropdown-item" onClick={() => onDropdownSelect('inherit')}>
Inherit
</div>
<div className="dropdown-item" onClick={() => onDropdownSelect('custom')}>
Custom
</div>
</Dropdown>
) : (
<div className="relative">
<input
id={id}
type="text"
className="block px-2 py-1 pr-6 rounded-sm outline-none transition-colors duration-100 w-24 h-8"
style={{
backgroundColor: theme.modal.input.bg,
border: `1px solid ${theme.modal.input.border}`,
color: theme.modal.input.text
}}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={value}
onChange={onValueChange}
onKeyDown={onKeyDown}
/>
<button
type="button"
onClick={onCustomValueReset}
className="absolute right-1 top-1/2 transform -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
title="Reset to inherit"
>
<IconX size={14} />
</button>
</div>
)}
</div>
</div>
);
};
export default InheritableSettingsInput;

View File

@@ -78,6 +78,14 @@ const StyledMarkdownBodyWrapper = styled.div`
background-color: ${(props) => props.theme.bg};
}
}
p {
white-space: pre-wrap;
}
div {
white-space: pre-wrap;
}
}
`;

View File

@@ -6,6 +6,9 @@ import { isValidUrl } from 'utils/url/index';
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = {
html: true,
breaks: true,
linkify: true,
replaceLink: function (link, env) {
return link.replace(/^\./, collectionPath);
}

View File

@@ -9,7 +9,8 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
<div className="bruno-modal-header">
{customHeader ? customHeader : <>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>}
{handleCancel && !hideClose ? (
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null}>
// TODO: Remove data-test-id and use data-testid instead across the codebase.
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-test-id="modal-close-button" data-testid="modal-close-button">
×
</div>
) : null}
@@ -71,7 +72,8 @@ const Modal = ({
disableCloseOnOutsideClick,
disableEscapeKey,
onClick,
closeModalFadeTimeout = 500
closeModalFadeTimeout = 500,
dataTestId
}) => {
const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false);
@@ -120,6 +122,7 @@ const Modal = ({
role="dialog"
aria-labelledby="modal-title"
aria-describedby="modal-description"
data-testid={dataTestId}
>
<ModalHeader
title={title}

View File

@@ -6,12 +6,27 @@ const StyledWrapper = styled.div`
max-height: 200px;
overflow: auto;
&.read-only {
.CodeMirror .CodeMirror-lines {
cursor: not-allowed !important;
user-select: none !important;
-webkit-user-select: none !important;
-ms-user-select: none !important;
}
.CodeMirror-line {
color: ${(props) => props.theme.colors.text.muted} !important;
}
}
.CodeMirror {
background: transparent;
height: fit-content;
font-size: 14px;
line-height: 30px;
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 200px;
pre.CodeMirror-placeholder {
color: ${(props) => props.theme.text};
@@ -19,18 +34,10 @@ const StyledWrapper = styled.div`
opacity: 0.5;
}
.CodeMirror-scroll {
overflow: visible !important;
position: relative;
display: block;
margin: 0px;
padding: 0px;
}
.CodeMirror-vscrollbar,
.CodeMirror-hscrollbar,
.CodeMirror-scrollbar-filler {
display: none;
display: none !important;
}
.CodeMirror-lines {

View File

@@ -3,7 +3,9 @@ import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -16,6 +18,10 @@ class MultiLineEditor extends Component {
this.cachedValue = props.value || '';
this.editorRef = React.createRef();
this.variables = {};
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
}
componentDidMount() {
// Initialize CodeMirror as a single line editor
@@ -23,22 +29,15 @@ class MultiLineEditor extends Component {
const variables = getAllVariables(this.props.collection, this.props.item);
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
lineNumbers: false,
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
placeholder: this.props.placeholder,
mode: 'brunovariables',
brunoVarInfo: {
variables
},
scrollbarStyle: null,
readOnly: this.props.readOnly ? 'nocursor' : false,
tabindex: 0,
extraKeys: {
Enter: () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
@@ -49,14 +48,6 @@ class MultiLineEditor extends Component {
this.props.onRun();
}
},
'Alt-Enter': () => {
this.editor.setValue(this.editor.getValue() + '\n');
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
},
'Shift-Enter': () => {
this.editor.setValue(this.editor.getValue() + '\n');
this.editor.setCursor({ line: this.editor.lineCount(), ch: 0 });
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
@@ -94,6 +85,10 @@ class MultiLineEditor extends Component {
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
// Initialize masking if this is a secret field
this.setState({ maskInput: this.props.isSecret });
this._enableMaskedEditor(this.props.isSecret);
}
_onEdit = () => {
@@ -105,6 +100,19 @@ class MultiLineEditor extends Component {
}
};
/** Enable or disable masking the rendered content of the editor */
_enableMaskedEditor = (enabled) => {
if (typeof enabled !== 'boolean') return;
if (enabled == true) {
if (!this.maskedEditor) this.maskedEditor = new MaskedEditor(this.editor, '*');
this.maskedEditor.enable();
} else {
this.maskedEditor?.disable();
this.maskedEditor = null;
}
};
componentDidUpdate(prevProps) {
// Ensure the changes caused by this update are not interpreted as
// user-input changes which could otherwise result in an infinite
@@ -119,12 +127,18 @@ class MultiLineEditor extends Component {
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.readOnly !== prevProps.readOnly && this.editor) {
this.editor.setOption('readOnly', this.props.readOnly ? 'nocursor' : false);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
}
if (this.editorRef?.current) {
this.editorRef.current.scrollTo(0, 10000);
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
this._enableMaskedEditor(this.props.isSecret);
// also set the maskInput flag to the new value
this.setState({ maskInput: this.props.isSecret });
}
this.ignoreChangeEvent = false;
}
@@ -133,6 +147,10 @@ class MultiLineEditor extends Component {
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
if (this.maskedEditor) {
this.maskedEditor.destroy();
this.maskedEditor = null;
}
this.editor.getWrapperElement().remove();
}
@@ -142,8 +160,39 @@ class MultiLineEditor extends Component {
this.editor.setOption('mode', 'brunovariables');
};
/**
* @brief Toggle the visibility of the secret value
*/
toggleVisibleSecret = () => {
const isVisible = !this.state.maskInput;
this.setState({ maskInput: isVisible });
this._enableMaskedEditor(isVisible);
};
/**
* @brief Eye icon to show/hide the secret value
* @returns ReactComponent The eye icon
*/
secretEye = (isSecret) => {
return isSecret === true ? (
<button className="mx-2" onClick={() => this.toggleVisibleSecret()}>
{this.state.maskInput === true ? (
<IconEyeOff size={18} strokeWidth={2} />
) : (
<IconEye size={18} strokeWidth={2} />
)}
</button>
) : null;
};
render() {
return <StyledWrapper ref={this.editorRef} className="single-line-editor"></StyledWrapper>;
const wrapperClass = `multi-line-editor grow ${this.props.readOnly ? 'read-only' : ''}`;
return (
<div className={`flex flex-row justify-between w-full overflow-x-auto ${this.props.className}`}>
<StyledWrapper ref={this.editorRef} className={wrapperClass} />
{this.secretEye(this.props.isSecret)}
</div>
);
}
}
export default MultiLineEditor;

View File

@@ -79,7 +79,7 @@ const Notifications = () => {
const modalCustomHeader = (
<div className="flex flex-row gap-8">
<div>NOTIFICATIONS</div>
<div className="bruno-modal-header-title">NOTIFICATIONS</div>
{unreadNotifications.length > 0 && (
<>
<div className="normal-case font-normal">

View File

@@ -11,9 +11,9 @@ import get from 'lodash/get';
// Beta features configuration
const BETA_FEATURES = [
{
id: 'grpc',
label: 'gRPC Support',
description: 'Enable gRPC request support for making gRPC calls to services'
id: 'nodevm',
label: 'Node VM Runtime',
description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
}
];
@@ -68,7 +68,7 @@ const Beta = ({ close }) => {
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
};
const hasAnyBetaFeatures = Object.values(formik.values).length > 0;
const hasAnyBetaFeatures = BETA_FEATURES.length > 0;
return (
<StyledWrapper>
@@ -79,7 +79,7 @@ const Beta = ({ close }) => {
<h2 className="text-lg font-semibold">Beta Features</h2>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4 text-wrap">
Enable beta features, these features may be unstable or incomplete.
Beta features are experimental previews that may change before full release. Try them and share feedback.
</p>
</div>

View File

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

View File

@@ -9,6 +9,7 @@ import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
@@ -27,6 +28,7 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
};
const Auth = ({ item, collection }) => {
const dispatch = useDispatch();
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
@@ -37,7 +39,7 @@ const Auth = ({ item, collection }) => {
// Save function for request level
const save = () => {
return saveRequest(item.uid, collection.uid);
return dispatch(saveRequest(item.uid, collection.uid));
};
const getEffectiveAuthSource = () => {

View File

@@ -17,229 +17,218 @@ import toast from 'react-hot-toast'
import { getAbsoluteFilePath } from 'utils/common/path';
const SingleGrpcMessage = ({ message, item, collection, index, methodType, isCollapsed, onToggleCollapse, handleRun, canClientSendMultipleMessages }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
// Access gRPC method metadata from local storage
const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
// Access gRPC method metadata from local storage
const [reflectionCache] = useLocalStorage('bruno.grpc.reflectionCache', {});
const [protofileCache] = useLocalStorage('bruno.grpc.protofileCache', {});
const { name, content } = message;
const canClientStream = methodType === 'client-streaming' || methodType === 'bidi-streaming';
const onEdit = (value) => {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: value
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const { name, content } = message;
const onEdit = (value) => {
const currentMessages = [...(body.grpc || [])];
const onSend = async () => {
try {
await sendGrpcMessage(item, collection.uid, content);
} catch (error) {
console.error('Error sending message:', error);
}
}
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRegenerateMessage = async () => {
try {
const methodPath = item.draft?.request?.method || item.request?.method;
if (!methodPath) {
toastError(new Error('Method path not found in request'));
return;
}
// Get the URL and protoPath to determine which cache to use
const url = item.draft?.request?.url || item.request?.url;
const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
// Find the method metadata from the appropriate cache
let methodMetadata = null;
if (protoPath) {
// Use protofile cache if protoPath is available
const absolutePath = getAbsoluteFilePath(protoPath, collection.pathname);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
} else if (url) {
// Use reflection cache if no protoPath (reflection mode)
const cachedMethods = reflectionCache[url];
if (cachedMethods) {
methodMetadata = cachedMethods.find(method => method.path === methodPath);
}
}
const result = await generateGrpcSampleMessage(
methodPath,
content,
{
arraySize: 2,
methodMetadata // Pass the method metadata to the function
}
);
if (result.success) {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: result.message
};
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
toast.success('Sample message generated successfully!');
} else {
toastError(new Error(result.error || 'Failed to generate sample message'));
}
} catch (error) {
console.error('Error generating sample message:', error);
toastError(error);
}
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: value
};
const onDeleteMessage = () => {
const currentMessages = [...(body.grpc || [])];
currentMessages.splice(index, 1);
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(
updateRequestBody({
content: currentMessages,
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}));
};
const onSend = async () => {
try {
await sendGrpcMessage(item, collection.uid, content);
} catch (error) {
console.error('Error sending message:', error);
}
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onRegenerateMessage = async () => {
try {
const methodPath = item.draft?.request?.method || item.request?.method;
if (!methodPath) {
toastError(new Error('Method path not found in request'));
return;
}
};
const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? "" : "h-80"}` : "h-full"
// Get the URL and protoPath to determine which cache to use
const url = item.draft?.request?.url || item.request?.url;
const protoPath = item.draft?.request?.protoPath || item.request?.protoPath;
return (
<div className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}>
<div
className="grpc-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed ?
<IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" /> :
<IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
}
<span className="font-medium text-sm">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? "Send gRPC message" : "Connection not active"} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
// Find the method metadata from the appropriate cache
let methodMetadata = null;
if (protoPath) {
// Use protofile cache if protoPath is available
const absolutePath = getAbsoluteFilePath(collection.pathname, protoPath);
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods) {
methodMetadata = cachedMethods.find((method) => method.path === methodPath);
}
} else if (url) {
// Use reflection cache if no protoPath (reflection mode)
const cachedMethods = reflectionCache[url];
if (cachedMethods) {
methodMetadata = cachedMethods.find((method) => method.path === methodPath);
}
}
const result = await generateGrpcSampleMessage(methodPath,
content,
{
arraySize: 2,
methodMetadata // Pass the method metadata to the function
});
if (result.success) {
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: result.message
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
toast.success('Sample message generated successfully!');
} else {
toastError(new Error(result.error || 'Failed to generate sample message'));
}
} catch (error) {
console.error('Error generating sample message:', error);
toastError(error);
}
};
const onDeleteMessage = () => {
const currentMessages = [...(body.grpc || [])];
currentMessages.splice(index, 1);
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
const onPrettify = () => {
try {
const edits = format(content, undefined, { tabSize: 2, insertSpaces: true });
const prettyBodyJson = applyEdits(content, edits);
const currentMessages = [...(body.grpc || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
}
};
const getContainerHeight = (canClientSendMultipleMessages && body.grpc.length > 1) ? `${isCollapsed ? '' : 'h-80'}` : 'h-full';
return (
<div className={`flex flex-col mb-3 border border-neutral-200 dark:border-neutral-800 rounded-md overflow-hidden ${getContainerHeight} relative`}>
<div
className="grpc-message-header flex items-center justify-between px-3 py-2 bg-neutral-100 dark:bg-neutral-700 cursor-pointer"
onClick={onToggleCollapse}
>
<div className="flex items-center gap-2">
{isCollapsed
? <IconChevronDown size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
: <IconChevronUp size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />}
<span className="font-medium text-sm">{`Message ${canClientStream ? index + 1 : ''}`}</span>
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<ToolHint text="Format JSON with proper indentation and spacing" toolhintId={`prettify-msg-${index}`}>
<button
onClick={onPrettify}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconWand size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
<ToolHint text="Generate a new sample message based on schema" toolhintId={`regenerate-msg-${index}`}>
<button
onClick={onRegenerateMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconRefresh size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
{canClientStream && (
<ToolHint text={isConnectionActive ? 'Send gRPC message' : 'Connection not active'} toolhintId={`send-msg-${index}`}>
<button
onClick={onSend}
disabled={!isConnectionActive}
className={`p-1 rounded ${isConnectionActive ? 'hover:bg-zinc-200 dark:hover:bg-zinc-600' : 'opacity-50 cursor-not-allowed'} transition-colors`}
>
<IconSend
size={16}
strokeWidth={1.5}
className={`${isConnectionActive ? 'text-zinc-700 dark:text-zinc-300' : 'text-zinc-400 dark:text-zinc-500'}`}
/>
</button>
</ToolHint>
)}
{index > 0 && (
<ToolHint text="Delete this message" toolhintId={`delete-msg-${index}`}>
<button
onClick={onDeleteMessage}
className="p-1 rounded hover:bg-zinc-200 dark:hover:bg-zinc-600 transition-colors"
>
<IconTrash size={16} strokeWidth={1.5} className="text-zinc-700 dark:text-zinc-300" />
</button>
</ToolHint>
)}
</div>
{!isCollapsed && (
<div className={`flex ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "h-80"} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode='application/ld+json'
enableVariableHighlighting={true}
/>
</div>
)}
</div>
)
}
{!isCollapsed && (
<div className={`flex ${body.grpc.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'h-80'} relative`}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode="application/ld+json"
enableVariableHighlighting={true}
/>
</div>
)}
</div>
);
};
const GrpcBody = ({ item, collection, handleRun }) => {
const preferences = useSelector((state) => state.app.preferences);
@@ -248,10 +237,10 @@ const GrpcBody = ({ item, collection, handleRun }) => {
const [collapsedMessages, setCollapsedMessages] = useState([]);
const messagesContainerRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
const canClientSendMultipleMessages = methodType === 'client-streaming' || methodType === 'bidi-streaming';
// Auto-scroll to the latest message when messages are added
useEffect(() => {
if (messagesContainerRef.current && body?.grpc?.length > 0) {
@@ -259,7 +248,7 @@ const GrpcBody = ({ item, collection, handleRun }) => {
container.scrollTop = container.scrollHeight;
}
}, [body?.grpc?.length]);
const toggleMessageCollapse = (index) => {
setCollapsedMessages(prev => {
if (prev.includes(index)) {
@@ -269,26 +258,23 @@ const GrpcBody = ({ item, collection, handleRun }) => {
}
});
};
const addNewMessage = () => {
const currentMessages = Array.isArray(body.grpc)
? [...body.grpc]
: [];
const currentMessages = Array.isArray(body.grpc)
? [...body.grpc]
: [];
currentMessages.push({
name: `message ${currentMessages.length + 1}`,
content: '{}'
});
dispatch(
updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
if (!body?.grpc || !Array.isArray(body.grpc)) {
return (
@@ -296,7 +282,7 @@ const GrpcBody = ({ item, collection, handleRun }) => {
<div className="flex flex-col items-center justify-center py-8">
<p className="text-zinc-500 dark:text-zinc-400 mb-4">No gRPC messages available</p>
<ToolHint text="Add the first message to your gRPC request" toolhintId="add-first-msg">
<button
<button
onClick={addNewMessage}
className="flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors"
>
@@ -308,21 +294,21 @@ const GrpcBody = ({ item, collection, handleRun }) => {
</StyledWrapper>
);
}
return (
<StyledWrapper isVerticalLayout={isVerticalLayout}>
<div
<div
ref={messagesContainerRef}
id="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? "h-full" : "overflow-y-auto"} ${canClientSendMultipleMessages && "pb-16"}`}
id="grpc-messages-container"
className={`flex-1 ${body.grpc.length === 1 || !canClientSendMultipleMessages ? 'h-full' : 'overflow-y-auto'} ${canClientSendMultipleMessages && 'pb-16'}`}
>
{body.grpc
.filter((_, index) => canClientSendMultipleMessages || index === 0)
.map((message, index) => (
<SingleGrpcMessage
<SingleGrpcMessage
key={index}
message={message}
item={item}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
@@ -331,13 +317,13 @@ const GrpcBody = ({ item, collection, handleRun }) => {
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
/>
))}
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-btn-container">
<ToolHint text="Add a new gRPC message to the request" toolhintId="add-msg-fixed">
<button
<button
onClick={addNewMessage}
className="add-message-btn flex items-center justify-center gap-2 py-2 px-4 rounded-md border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors shadow-md"
>
@@ -351,4 +337,4 @@ const GrpcBody = ({ item, collection, handleRun }) => {
);
};
export default GrpcBody;
export default GrpcBody;

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconCheck, IconCopy } from '@tabler/icons';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import Modal from 'components/Modal/index';
import CodeEditor from 'components/CodeEditor';
const GrpcurlModal = ({ isOpen, onClose, command }) => {
const { displayedTheme } = useTheme();
const [copied, setCopied] = useState(false);
const preferences = useSelector((state) => state.app.preferences);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(command);
setCopied(true);
toast.success('Command copied to clipboard');
setTimeout(() => setCopied(false), 2000);
} catch (error) {
toast.error('Failed to copy command');
}
};
return (
<Modal
isOpen={isOpen}
handleCancel={onClose}
title={(
<div className="flex items-center gap-2">
<span>Generate gRPCurl Command</span>
</div>
)}
size="lg"
hideFooter={true}
>
<div>
<div className="flex w-full min-h-[400px]">
<div className="flex-grow relative">
<div className="absolute top-2 right-2 z-10">
<button
onClick={handleCopy}
className="btn btn-sm btn-secondary flex items-center gap-2"
>
{copied ? <IconCheck size={20} /> : <IconCopy size={20} />}
</button>
</div>
<CodeEditor
value={command}
theme={displayedTheme}
readOnly={true}
mode="shell"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
/>
</div>
</div>
</div>
</Modal>
);
};
export default GrpcurlModal;

View File

@@ -0,0 +1,131 @@
import React, { forwardRef } from 'react';
import { IconChevronDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown/index';
import {
IconGrpcUnary,
IconGrpcClientStreaming,
IconGrpcServerStreaming,
IconGrpcBidiStreaming
} from 'components/Icons/Grpc';
const MethodDropdown = ({
grpcMethods,
selectedGrpcMethod,
onMethodSelect,
onMethodDropdownCreate
}) => {
const groupMethodsByService = (methods) => {
if (!methods || !methods.length) return {};
const groupedMethods = {};
methods.forEach((method) => {
const pathWithoutLeadingSlash = method.path.startsWith('/') ? method.path.slice(1) : method.path;
const parts = pathWithoutLeadingSlash.split('/');
const serviceName = parts[0] || 'Default';
const methodName = parts[1] || method.path;
const enhancedMethod = {
...method,
serviceName,
methodName
};
if (!groupedMethods[serviceName]) {
groupedMethods[serviceName] = [];
}
groupedMethods[serviceName].push(enhancedMethod);
});
return groupedMethods;
};
const getIconForMethodType = (type) => {
switch (type) {
case 'unary':
return <IconGrpcUnary size={20} strokeWidth={2} />;
case 'client-streaming':
return <IconGrpcClientStreaming size={20} strokeWidth={2} />;
case 'server-streaming':
return <IconGrpcServerStreaming size={20} strokeWidth={2} />;
case 'bidi-streaming':
return <IconGrpcBidiStreaming size={20} strokeWidth={2} />;
default:
return <IconGrpcUnary size={20} strokeWidth={2} />;
}
};
const MethodsDropdownIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center ml-2 cursor-pointer select-none">
{selectedGrpcMethod && <div className="mr-2">{getIconForMethodType(selectedGrpcMethod.type)}</div>}
<span className="text-xs">
{selectedGrpcMethod ? (
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap">
{selectedGrpcMethod.path.split('.').at(-1) || selectedGrpcMethod.path}
</span>
) : (
<span className="dark:text-neutral-300 text-neutral-700 text-nowrap">Select Method </span>
)}
</span>
<IconChevronDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
);
});
const handleGrpcMethodSelect = (method) => {
const methodType = method.type;
onMethodSelect({ path: method.path, type: methodType });
};
if (!grpcMethods || grpcMethods.length === 0) {
return null;
}
return (
<div className="flex items-center h-full mr-2" data-testid="grpc-methods-dropdown">
<Dropdown onCreate={onMethodDropdownCreate} icon={<MethodsDropdownIcon />} placement="bottom-end" style={{ maxWidth: 'unset' }}>
<div className="max-h-96 overflow-y-auto max-w-96 min-w-60" data-testid="grpc-methods-list">
{Object.entries(groupMethodsByService(grpcMethods)).map(([serviceName, methods], serviceIndex) => (
<div key={serviceIndex} className="service-group mb-2">
<div className="service-header px-3 py-1 bg-neutral-100 dark:bg-neutral-800 text-sm font-medium truncate sticky top-0 z-10">
{serviceName || 'Default Service'}
</div>
<div className="service-methods">
{methods.map((method, methodIndex) => (
<div
key={`${serviceIndex}-${methodIndex}`}
className={`py-2 px-3 w-full border-l-2 transition-all duration-200 relative group ${
selectedGrpcMethod && selectedGrpcMethod.path === method.path
? 'border-yellow-500 bg-yellow-500/20 dark:bg-yellow-900/20'
: 'border-transparent hover:bg-black/5 dark:hover:bg-white/5'
}`}
onClick={() => handleGrpcMethodSelect(method)}
data-testid="grpc-method-item"
>
<div className="flex items-center">
<div className="text-xs mr-3 text-gray-500">
{getIconForMethodType(method.type)}
</div>
<div className="flex flex-col flex-1">
<div className="font-medium text-gray-900 dark:text-gray-100">
{method.methodName}
</div>
<div className="text-xs text-gray-500">
{method.type}
</div>
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</Dropdown>
</div>
);
};
export default MethodDropdown;

View File

@@ -0,0 +1,217 @@
import React, { forwardRef, useState } from 'react';
import { IconFile, IconChevronDown } from '@tabler/icons';
import { getBasename } from 'utils/common/path';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { updateRequestProtoPath } from 'providers/ReduxStore/slices/collections';
import { openCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import Dropdown from 'components/Dropdown/index';
import ToggleSwitch from 'components/ToggleSwitch/index';
import { TabNavigation, ProtoFilesTab, ImportPathsTab } from '../Tabs';
import useProtoFileManagement from 'hooks/useProtoFileManagement/index';
const ProtoFileDropdown = ({
collection,
item,
isReflectionMode,
protoFilePath,
showProtoDropdown,
setShowProtoDropdown,
onProtoDropdownCreate,
onReflectionModeToggle,
onProtoFileLoad
}) => {
const { theme } = useTheme();
const dispatch = useDispatch();
const [activeTab, setActiveTab] = useState('protofiles'); // 'protofiles' or 'importpaths'
const protoFileManagement = useProtoFileManagement(collection, protoFilePath);
const invalidProtoFiles = protoFileManagement.protoFiles.filter((file) => !file.exists);
const invalidImportPaths = protoFileManagement.importPaths.filter((path) => !path.exists);
const handleSelectProtoFile = async (e) => {
e.stopPropagation();
const { success, filePath, error } = await protoFileManagement.browseForProtoFile();
if (!success) {
if (error) {
toast.error(`Failed to browse for proto file: ${error.message}`);
}
return;
}
const { success: addSuccess, relativePath, alreadyExists, error: addError } = await protoFileManagement.addProtoFileToCollection(filePath);
if (!addSuccess) {
if (addError) {
toast.error(`Failed to add proto file: ${addError.message}`);
}
return;
}
if (alreadyExists) {
toast.error('Proto file already exists in collection settings');
} else {
toast.success('Added proto file to collection');
}
dispatch(updateRequestProtoPath({
protoPath: relativePath,
itemUid: item.uid,
collectionUid: collection.uid
}));
setShowProtoDropdown(false);
onProtoFileLoad(relativePath);
};
const handleSelectCollectionProtoFile = (protoFile) => {
if (!protoFile || !protoFile.exists) {
toast.error('Proto file not found');
return;
}
setShowProtoDropdown(false);
dispatch(updateRequestProtoPath({
protoPath: protoFile.path,
itemUid: item.uid,
collectionUid: collection.uid
}));
onProtoFileLoad(protoFile.path);
};
const handleBrowseImportPath = async (e) => {
e.stopPropagation();
const { success, directoryPath, error } = await protoFileManagement.browseForImportDirectory();
if (!success) {
if (error) {
toast.error(`Failed to browse for import directory: ${error.message}`);
}
return;
}
const { success: addSuccess, error: addError } = await protoFileManagement.addImportPathToCollection(directoryPath);
if (!addSuccess) {
if (addError) {
toast.error(`Failed to add import path: ${addError.message}`);
}
return;
}
toast.success('Added import path to collection');
};
const handleToggleImportPath = async (index) => {
const { success, enabled, error } = await protoFileManagement.toggleImportPath(index);
if (!success) {
if (error) {
toast.error(`Failed to toggle import path: ${error.message}`);
}
return;
}
toast.success(`Import path ${enabled ? 'enabled' : 'disabled'}`);
};
const handleOpenCollectionProtobufSettings = (e) => {
e.stopPropagation();
dispatch(openCollectionSettings(collection.uid, 'protobuf'));
};
const ProtoFileDropdownIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center cursor-pointer select-none" onClick={() => setShowProtoDropdown((prev) => !prev)} data-testid="grpc-proto-file-dropdown-icon">
{isReflectionMode ? (<></>
) : (
<IconFile size={20} strokeWidth={1.5} className="mr-1 text-neutral-400" />
)}
<span className="text-xs dark:text-neutral-300 text-neutral-700 text-nowrap">
{isReflectionMode ? 'Using Reflection' : (protoFilePath ? getBasename(collection.pathname, protoFilePath) : 'Select Proto File')}
</span>
<IconChevronDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
);
});
return (
<div className="proto-file-dropdown">
<Dropdown
onCreate={onProtoDropdownCreate}
icon={<ProtoFileDropdownIcon />}
placement="bottom-end"
visible={showProtoDropdown}
onClickOutside={() => setShowProtoDropdown(false)}
data-testid="grpc-proto-file-dropdown"
>
<div className="max-h-fit overflow-y-auto w-[30rem]">
<div className="px-3 py-2 border-b border-neutral-200 dark:border-neutral-700" data-testid="grpc-mode-toggle">
<div className="flex items-center justify-between">
<span className="text-sm">Mode</span>
<div className="flex items-center gap-2">
<span className={`text-xs ${!isReflectionMode ? 'font-medium' : 'text-neutral-500'}`} style={{ color: !isReflectionMode ? theme.colors.text.yellow : undefined }}>
Proto File
</span>
<ToggleSwitch
isOn={isReflectionMode}
handleToggle={onReflectionModeToggle}
size="2xs"
activeColor={theme.colors.text.yellow}
/>
<span className={`text-xs ${isReflectionMode ? 'font-medium' : 'text-neutral-500'}`} style={{ color: isReflectionMode ? theme.colors.text.yellow : undefined }}>
Reflection
</span>
</div>
</div>
</div>
{!isReflectionMode && (
<TabNavigation
activeTab={activeTab}
onTabChange={setActiveTab}
collectionProtoFiles={protoFileManagement.protoFiles}
collectionImportPaths={protoFileManagement.importPaths}
/>
)}
{!isReflectionMode && (
<>
{activeTab === 'protofiles' && (
<ProtoFilesTab
collectionProtoFiles={protoFileManagement.protoFiles}
invalidProtoFiles={invalidProtoFiles}
protoFilePath={protoFilePath}
collection={collection}
onSelectCollectionProtoFile={handleSelectCollectionProtoFile}
onOpenCollectionProtobufSettings={handleOpenCollectionProtobufSettings}
onSelectProtoFile={handleSelectProtoFile}
setShowProtoDropdown={setShowProtoDropdown}
/>
)}
{activeTab === 'importpaths' && (
<ImportPathsTab
collectionImportPaths={protoFileManagement.importPaths}
invalidImportPaths={invalidImportPaths}
onOpenCollectionProtobufSettings={handleOpenCollectionProtobufSettings}
onBrowseImportPath={handleBrowseImportPath}
onToggleImportPath={handleToggleImportPath}
/>
)}
</>
)}
{isReflectionMode && (
<div className="px-3 py-2">
<div className="text-sm text-neutral-600 dark:text-neutral-400 mb-2">
Using server reflection to discover gRPC methods.
</div>
</div>
)}
</div>
</Dropdown>
</div>
);
};
export default ProtoFileDropdown;

View File

@@ -0,0 +1,154 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.content-wrapper {
padding: 0.5rem 0.75rem;
}
.header-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.25rem;
}
.header-text {
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.importPaths.header.text};
}
.settings-button {
color: ${(props) => props.theme.grpc.importPaths.header.button.color};
background: transparent;
border: none;
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
transition: color 0.2s ease;
&:hover {
color: ${(props) => props.theme.grpc.importPaths.header.button.hoverColor};
}
}
.error-wrapper {
margin-bottom: 0.5rem;
padding: 0.5rem;
background-color: ${(props) => props.theme.grpc.importPaths.error.bg};
border-radius: 0.25rem;
font-size: 0.75rem;
color: ${(props) => props.theme.grpc.importPaths.error.text};
}
.error-text {
display: flex;
align-items: center;
margin: 0;
}
.error-link {
color: ${(props) => props.theme.grpc.importPaths.error.link.color};
background: transparent;
border: none;
cursor: pointer;
text-decoration: underline;
margin-left: 0.25rem;
font-size: inherit;
&:hover {
color: ${(props) => props.theme.grpc.importPaths.error.link.hoverColor};
}
}
.items-container {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 15rem;
overflow: auto;
max-width: 30rem;
}
.item-wrapper {
padding: 0.5rem 0.75rem;
opacity: ${(props) => props.theme.grpc.importPaths.item.invalid.opacity};
&.valid {
opacity: 1;
}
}
.item-content {
display: flex;
align-items: center;
justify-content: space-between;
}
.item-left {
display: flex;
align-items: center;
}
.checkbox-wrapper {
display: flex;
align-items: center;
margin-right: 0.75rem;
}
.checkbox {
margin-right: 0.5rem;
cursor: pointer;
color: ${(props) => props.theme.grpc.importPaths.item.checkbox.color};
}
.item-text {
display: flex;
align-items: center;
font-size: 0.75rem;
white-space: nowrap;
}
.invalid-icon {
color: ${(props) => props.theme.grpc.importPaths.item.invalid.text};
font-size: 0.75rem;
display: flex;
align-items: center;
}
.empty-wrapper {
padding: 0.5rem 0.75rem;
}
.empty-text {
color: ${(props) => props.theme.grpc.importPaths.empty.text};
font-size: 0.875rem;
font-style: italic;
text-align: center;
padding: 0.5rem 0;
}
.button-wrapper {
padding: 0.5rem 0.75rem;
}
.browse-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: ${(props) => props.theme.grpc.importPaths.button.bg};
color: ${(props) => props.theme.grpc.importPaths.button.color};
border: 1px solid ${(props) => props.theme.grpc.importPaths.button.border};
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
cursor: pointer;
transition: border-color 0.2s ease;
&:hover {
border-color: ${(props) => props.theme.grpc.importPaths.button.hoverBorder};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,102 @@
import React from 'react';
import { IconFolder, IconSettings, IconAlertCircle, IconFileImport } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const ImportPathsTab = ({
collectionImportPaths,
invalidImportPaths,
onOpenCollectionProtobufSettings,
onBrowseImportPath,
onToggleImportPath
}) => {
return (
<StyledWrapper>
{collectionImportPaths && collectionImportPaths.length > 0 && (
<div className="content-wrapper">
<div className="header-wrapper">
<div className="header-text">From Collection Settings</div>
<button
onClick={onOpenCollectionProtobufSettings}
className="settings-button"
>
<IconSettings size={16} strokeWidth={1.5} />
</button>
</div>
{invalidImportPaths.length > 0 && (
<div className="error-wrapper">
<p className="error-text">
<IconAlertCircle size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Some import paths could not be found.
{' '}
<button
onClick={onOpenCollectionProtobufSettings}
className="error-link"
>
Manage import paths
</button>
</p>
</div>
)}
<div className="items-container">
{collectionImportPaths.map((importPath, index) => {
const isInvalid = !importPath.exists;
return (
<div
key={`collection-import-${index}`}
className={`item-wrapper ${!isInvalid ? 'valid' : ''}`}
>
<div className="item-content">
<div className="item-left">
<div className="checkbox-wrapper">
<input
type="checkbox"
checked={importPath.enabled}
disabled={isInvalid}
onChange={() => onToggleImportPath(index)}
className="checkbox"
title={importPath.enabled ? 'Import path enabled' : 'Import path disabled'}
/>
</div>
<IconFolder size={20} strokeWidth={1.5} style={{ marginRight: '0.5rem', color: 'inherit' }} />
<div className="item-text">
{importPath.path}
{isInvalid && (
<span className="invalid-icon">
<IconAlertCircle size={16} strokeWidth={1.5} style={{ margin: '0 0.25rem' }} />
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
{(!collectionImportPaths || collectionImportPaths.length === 0) && (
<div className="empty-wrapper">
<div className="empty-text">
No import paths configured in collection settings
</div>
</div>
)}
<div className="button-wrapper">
<button
className="browse-button"
onClick={onBrowseImportPath}
>
<IconFileImport size={16} strokeWidth={1.5} style={{ marginRight: '0.25rem' }} />
Browse for Import Path
</button>
</div>
</StyledWrapper>
);
};
export default ImportPathsTab;

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