Compare commits

...

115 Commits

Author SHA1 Message Date
dependabot[bot]
8f37eb2d1f chore(deps): bump actions/github-script from 7 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 17:34:29 +00:00
lohit
cd06f28430 fix: rename signatureEncoding to signatureMethod for OAuth1 in opencollection types (#7724)
Align with @opencollection/types 0.9.1 which renamed the OAuth1 field from
signatureEncoding to signatureMethod. Update converters, filestore, and all
YML test fixtures. Increase OAuth1 UI test timeouts from 30s to 60s.
2026-04-09 23:36:39 +05:30
Sid
3b502fd63d give sid-bruno some nice privileges (#7702) 2026-04-08 11:36:18 +05:30
naman-bruno
d4cd34fc50 fix: fix scroll in querybar component (#7700) 2026-04-07 19:25:51 +05:30
Pragadesh-45
58942b383d Feat: Support PAC file upload (#7651)
* Add proxy .pac file resolver

chore(dependencies): update package-lock.json with new dependencies and version upgrades

- Added new dependencies: ajv, git-url-parse, @opencollection/types, and storybook packages.
- Updated existing dependencies to their latest versions, including eslint and babel packages.
- Removed deprecated entries and cleaned up the package-lock structure for better maintainability.

* tests

* wip

* wip

* wip

* wip

* feat: file upload .pac

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* feat: Refactor proxy settings to use a new structure for PAC configuration. Introduced 'source' field to determine proxy type (manual or PAC) and updated related validation and state management. Removed deprecated 'pacUrl' field in favor of 'pac.source'. Updated preferences schema and test data accordingly.

* fix: Update proxy settings to correctly reference 'source' field for PAC configuration. Adjusted state management and validation logic to align with new structure. Enhanced tests for backward compatibility and new format handling.

* feat: Enhance proxy configuration by adding 'proxyModeReason' to provide context for proxy settings. Updated related functions to accommodate the new parameter and improved logging for proxy mode changes.

* wip

* refactor: Update proxy settings to remove 'inherit' field and replace it with 'source' for better clarity. Adjusted validation schema, default preferences, and migration logic to align with the new structure. Enhanced tests to ensure compatibility with the updated proxy configuration.

* wip

* wip

* wip

* wip

* wip

* chore: consistent path check

* chore: consistency

* tests(pac): fix unit params

---------

Co-authored-by: Gianluca D'Abrosca <gianluca.dabrosca.1999@gmail.com>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-07 17:00:32 +05:30
gopu-bruno
476d30a49e feat: replace send button with Send/Cancel buttons on request url (#7675)
* feat: replace request send icon with Send/Cancel buttons

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
2026-04-07 13:42:09 +05:30
Pooja
4d6032ba0d fix: timeline url race condition (#7154)
* fix: timeline url race condition

* add: requestSent in catch block

* add: requestSent in catch
2026-04-06 17:09:37 +05:30
Abhishek S Lal
fabba4d296 fix: resolve process.env variables in global environment level (#7600)
* feat: enhance environment variable resolution in EnvironmentVariablesTable

- Added logic to populate process environment variables from the active workspace when no collection is selected, allowing for proper resolution of {{process.env.X}}.
- Updated workspace actions to map scratch collections to their respective workspaces, ensuring that environment variables can be resolved correctly.

This improves the user experience by providing access to workspace-specific environment variables in the environment variables table.

* refactor: improve state selection and add test IDs for better testing

- Refactored the state selection logic in EnvironmentVariablesTable for clarity.
- Added data-testid attributes to various components including CollapsibleSection, DotEnvFileDetails, DotEnvRawView, and EnvironmentList to enhance testability.
- This change aims to streamline component interactions and facilitate easier testing.

* feat: add test IDs to EnvironmentList for improved testability

- Introduced data-testid attributes to the EnvironmentList component, enhancing the ability to target elements in tests.
- This update includes test IDs for the CollapsibleSection, create .env file button, and the input field for the .env name, facilitating better integration with testing frameworks.

* refactor: simplify global environment test setup

- Removed unnecessary timeout setting and afterEach cleanup logic from the global environment process.env resolution test.
- This change streamlines the test structure, focusing on the core functionality being tested.
2026-04-04 14:57:50 +05:30
Chirag Chandrashekhar
c273c10f0c fix: add uuid v7 support in pre-request scripts (#7377) 2026-04-03 17:20:23 +05:30
Chirag Chandrashekhar
073b1ef036 fix: validate environment variables in unsaved changes dialog (#7403) 2026-04-03 17:05:05 +05:30
Abhishek S Lal
5db34dff11 fix: allow __Host- prefixed cookies to be stored via script API (#7549)
* fix: allow __Host- prefixed cookies to be stored via script API (#7452)

* refactor: rename URL constant to TEST_URL for clarity in cookie tests
2026-04-03 15:03:14 +05:30
Chirag Chandrashekhar
233013df20 fix: clear inherited DNS lookup for non-localhost URLs in redirect handling (#7426) 2026-04-03 14:44:55 +05:30
Pooja
5086ac4b8c fix: graphql doc close button (#7667)
* fix: graphql doc close button

* fix

* fix
2026-04-03 13:57:39 +05:30
Bijin A B
f112c4fdd8 fix: sample collection creation race condition (#7665) 2026-04-03 13:55:12 +05:30
Chirag Chandrashekhar
5e1a36f8c8 fix: close previous SSE connection before sending new request (#7474)
* fix: update system proxy fetching to use finally (#7652)

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

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

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

---------

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

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

* fix: close previous SSE connection before sending new request

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

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

* fix: address PR review feedback for SSE connection cancellation

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

* fix: updated the test to check of connectionIds

---------

Co-authored-by: Sid <siddharth@usebruno.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Pooja <pooja@usebruno.com>
Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-03 01:59:11 +05:30
Abhishek S Lal
9465de02ee fix: support response query filtering in safe mode (#7441)
* fix(js): support response query filters in safe mode

* feat: add tests and improve bruno-response shim for query argument handling

- Introduced a new test suite for the bruno-response shim to validate query filtering and function callback behavior.
- Refactored the `res` function to enhance argument handling, allowing for better marshalling of QuickJS function handles and other values.
- Improved error handling within the response processing to ensure robust behavior during query execution.

* fix: correct argument handling in bruno-response shim for function callbacks

- Updated the `toHostQueryArg` function to use `vm.undefined` instead of `vm.global` when calling the provided function argument. This change ensures proper context handling during function execution, improving the reliability of query argument processing.

* chore: clean up .gitignore and enhance error handling in bruno-response tests

- Removed redundant entries from .gitignore to streamline ignored files.
- Improved error handling in the `afterEach` and `afterAll` hooks of the bruno-response tests to ensure proper disposal of resources, preventing potential memory leaks.

---------

Co-authored-by: cryst <230207759+cryst-hq@users.noreply.github.com>
2026-04-03 01:53:11 +05:30
Abhishek S Lal
5c1dc1184a fix: isJson assertion should accept arrays as valid JSON (#7620)
* fix(assert-runtime): update JSON validation to allow arrays and enhance test coverage

- Modified the JSON validation logic to accept arrays as valid JSON objects.
- Updated tests to ensure correct behavior for various array scenarios, including empty arrays and arrays of strings and objects.

* fix(assert-runtime): refine JSON validation logic to correctly handle arrays

- Updated the JSON validation to allow arrays as valid JSON objects, ensuring compatibility with various data structures.
- Adjusted the test assertions to reflect the new validation criteria.
2026-04-03 01:47:57 +05:30
lohit
bae5934137 perf: optimize DNS resolution to reduce request latency (#7664)
* perf: optimize DNS resolution to reduce request latency by ~31%

Replace default dns.lookup (libuv thread pool) with async dns.resolve4/6
(c-ares) that bypasses the thread pool bottleneck, falling back to
dns.lookup for /etc/hosts and mDNS hostnames.

* fix: address PR review feedback for DNS optimization

- Guard against undefined options in fastLookup to prevent runtime errors
- Document that options.family is not yet respected (safe today, noted for future)
- Replace callback as any with proper typed forwarding wrapper
- Extract shared agent config (defaultAgentOptions) to eliminate duplication
  between axios-instance.ts and agent-cache.ts, with documented rationale
- Mock DNS in test to avoid real network calls to google.com in CI

* fix: removed explicit resolve6 in fastLookup

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-02 21:27:51 +05:30
shubh-bruno
5cd3e7abbd fix: handle copy/paste item for requests & js file (#7656) 2026-04-02 18:13:37 +05:30
Chirag Chandrashekhar
765c9f1060 fix: add size and duration fields to CLI bru.runRequest() response (#7429)
* fix: add size and duration fields to CLI bru.runRequest() response

Add `size` and `duration` fields to the response object in CLI to match
GUI behavior, ensuring consistent API for bru.runRequest() across both
environments.

- `duration` is an alias for `responseTime` for GUI compatibility
- `size` is the byte length of the response buffer (0 for errors/skipped)

Fixes #7352

* fix: address PR review feedback for CLI response consistency

- Coerce responseTime header to number (was string from headers.get())
- Add comment explaining duration vs responseTime difference between
  GUI (wall-clock) and CLI (approximation using responseTime)
- Add integration tests for duration/size fields across skipped,
  success, and network error response paths

* fix: add missing setupProxyAgents mock in response-fields test

The success path calls setupProxyAgents which was missing from the
proxy-util mock, causing CI failure.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-02 17:55:42 +05:30
Pooja
7ddd2d3f17 fix: allow file selection in multipart form without entering a key first (#7640) 2026-04-02 11:38:36 +05:30
Sid
ce87289616 fix: update system proxy fetching to use finally (#7652)
* fix: update system proxy fetching to use finally for improved reliability

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

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-01 23:23:43 +05:30
sanish chirayath
c7ebe25cd6 Fix: ensure string authvalues, string header processing (#7646)
* feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion

- Introduced `ensureString` function to convert numeric and non-string values to strings, defaulting null/undefined to an empty string.
- Updated request handling in `importPostmanV2CollectionItem` to utilize `ensureString` for headers, parameters, and body fields.
- Added tests to verify correct conversion of numeric values to strings in headers, query parameters, and body fields.

* test: add test for numeric value conversion in Postman to Bruno transformation

- Implemented a new test case to verify that numeric values in example request and response fields are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks various components including request headers, query parameters, path parameters, and body fields to ensure proper string conversion.

* test: add multipart form value test for numeric conversion in Postman to Bruno transformation

- Added a new test case to verify that numeric values in multipart form data are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks the conversion of numeric values in the request body to ensure proper handling in the transformation.

* feat: enhance header parsing in Postman to Bruno conversion

- Added `parseStringHeader` and `normalizeHeaders` functions to handle various header formats, including string headers and concatenated strings.
- Updated the request and response handling in `importPostmanV2CollectionItem` to utilize the new header normalization logic.
- Introduced tests to verify correct parsing of string headers, including cases with no values and concatenated headers.

* refactor: enhance ensureString function for flexible fallback values

- Updated the `ensureString` function to accept a fallback parameter, allowing for customizable default values instead of a fixed empty string for null/undefined inputs.
- Modified the usage of `ensureString` in the `processAuth` function to utilize the new fallback feature for various authentication fields, improving the handling of optional values.

* refactor: update ensureString function to handle empty values

- Modified the `ensureString` function to return the fallback for null, undefined, or empty string values, enhancing its flexibility in handling various input scenarios.

* chore: update ESLint configuration and enhance Postman to Bruno conversion tests

- Added 'no-case-declarations' rule to ESLint configuration to enforce stricter coding standards.
- Modified the `processAuth` function to ensure proper block scoping for OAuth2 case handling.
- Improved header parsing logic to check for string type in content-type header.
- Added new tests to verify conversion of numeric authentication values to strings in both array-backed and object-backed formats during Postman to Bruno transformation.

* chore: update ESLint configuration to enforce stricter rules

- Added 'no-case-declarations' rule to ESLint configuration to enhance code quality.
- Adjusted existing rules for consistency and clarity in the configuration.

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2026-04-01 21:41:05 +05:30
lohit
0a9988f80d feat: add gRPC proxy support (#7575)
* feat: add gRPC proxy support

* fix: respect channelOptions grpc.primary_user_agent over User-Agent header

* fix: remove non-standard grpc_proxy/no_grpc_proxy env var support

These are not recognized by any standard gRPC implementation. gRPC proxy
now uses the standard http_proxy/https_proxy/no_proxy variables like
grpc-core, grpc-go, and grpc-java.

* chore: add resolveGrpcProxyConfig tests and clean up grpc-client

Export resolveGrpcProxyConfig for testability and add unit tests covering
all proxy modes (off, on, system), auth encoding, protocol rejection,
bypass lists, and edge cases. Remove redundant cancelAndCloseConnection
call in startConnection (already guarded by addConnection). Document why
internal @grpc/grpc-js channel options are used for programmatic proxy.
2026-04-01 21:39:34 +05:30
Abhishek S Lal
d73e01993d feat(request-tabs): prevent browser autoscroll on middle-button mouse actions (#7443)
Added event handlers to prevent the browser's autoscroll behavior when the middle mouse button is pressed in the ExampleTab and RequestTab components. This improves user experience by avoiding unintended scrolling during tab interactions.
2026-04-01 21:36:31 +05:30
Abhishek S Lal
64bdef23ec fix: include examples when writing collection items in CLI OpenAPI import (#7613)
* feat: add support for examples in collection items

- Enhanced the processCollectionItems function to include examples from imported collection items.
- Added a new test case to verify that examples are correctly written to the output file during collection creation.

* fix: coerce response status to number in collection creation tests

Updated the test for createCollectionFromBrunoObject to ensure the response status is compared as a number, improving type consistency in assertions.
2026-04-01 21:34:23 +05:30
Abhishek S Lal
97467c57bf feat: add blur event handling to MultiLineEditor and SingleLineEditor components (#7619)
- Implemented a new _onBlur method to set the cursor position when the editor loses focus.
- Updated event listeners to include the blur event for both MultiLineEditor and SingleLineEditor, enhancing user experience by preserving cursor position.
- Ensured proper cleanup of event listeners during component unmounting to prevent memory leaks.
2026-04-01 21:33:18 +05:30
Abhishek S Lal
c8abb5be16 fix: forward cookies from 4XX/5XX responses in runner and CLI (#7498)
When axios receives a 4XX/5XX response it throws an error, causing
execution to jump to the catch block. saveCookies() was only called
in the try block (2XX path), so error-status cookies were silently
dropped in collection runs.

Fix applied to both affected code paths:
- packages/bruno-cli/src/runner/run-single-request.js (CLI runner)
- packages/bruno-electron/src/ipc/network/index.js (app collection runner)

Manual single-request execution was already correct — saveCookies() is
called after the try/catch there, so both status paths were covered.

Fixes #7475
2026-04-01 21:24:32 +05:30
gopu-bruno
8e978ae305 Add support for importing Swagger 2.0 specifications into Bruno collections (#7622)
* feat: support Swagger 2.0 OpenAPI import

* feat: support Swagger 2.0 OpenAPI import

* fix: refactor swagger2 converter, fix env creation, and update import UI labels

* fix: coderabbit comments

* fix: address coderabbit comments for body type handling

* fix: disallow OpenAPI Sync for Swagger 2.0 specs in UI

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
2026-04-01 21:19:47 +05:30
Abhishek S Lal
00bc93d3ac fix: ensure tags are always an array in bruToJson function (#7631)
Updated the bruToJson function to coerce the tags property into an array, ensuring consistent data structure when processing JSON input. This change enhances the reliability of the function by preventing potential errors when tags are not provided as an array.
2026-04-01 21:14:31 +05:30
Abhishek S Lal
3c3acf33a0 fix: ensure tags are always an array in parseBruRequest function (#7616)
Updated the parseBruRequest function to guarantee that the tags extracted from the JSON input are always returned as an array, improving data consistency and preventing potential errors when handling non-array values.
2026-04-01 21:12:47 +05:30
Chirag Chandrashekhar
8c9cad6d78 fix: cURL paste not updating request tab editors visually (#7610)
* fix: cURL paste not updating request tab editors visually

Remove the focus-based guard from PR#7098 that blocked editor value
updates when the editor had focus. This caused cURL paste to not
reflect in the request tab until switching tabs. Editors now always
accept incoming prop values and preserve cursor position. Also set
cursor to end of new URL after cURL paste using setTimeout.

* fix: added optional chaining

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-01 21:03:10 +05:30
lohit
0b3f5100e7 fix: recreate HTTP/HTTPS agents on redirect to prevent stale agent reuse (#7597) (#7615)
When a request redirected from HTTP to HTTPS (or vice versa), the
original httpAgent/httpsAgent leaked into the redirect config. The
httpsAgent — which carries custom CA certificates and TLS options — was
never created for the redirect URL, causing UNABLE_TO_VERIFY_LEAF_SIGNATURE.

Changes:
- setupProxyAgents (electron) now deletes stale agents at the top of
  every call so they are always recreated for the current URL
- setupProxyAgents extracted to bruno-cli/proxy-util.js (mirrors the
  electron version) and called on every redirect in the CLI path
- Removed the else-branch in bruno-requests/http-https-agents.ts that
  only created one agent based on initial protocol
- Added HTTP→HTTPS redirect test server and request to the
  custom-ca-certs SSL test suite
2026-04-01 20:55:28 +05:30
sanish chirayath
c502f959b4 feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion (#7644)
* feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion

- Introduced `ensureString` function to convert numeric and non-string values to strings, defaulting null/undefined to an empty string.
- Updated request handling in `importPostmanV2CollectionItem` to utilize `ensureString` for headers, parameters, and body fields.
- Added tests to verify correct conversion of numeric values to strings in headers, query parameters, and body fields.

* test: add test for numeric value conversion in Postman to Bruno transformation

- Implemented a new test case to verify that numeric values in example request and response fields are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks various components including request headers, query parameters, path parameters, and body fields to ensure proper string conversion.

* test: add multipart form value test for numeric conversion in Postman to Bruno transformation

- Added a new test case to verify that numeric values in multipart form data are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks the conversion of numeric values in the request body to ensure proper handling in the transformation.
2026-04-01 20:45:51 +05:30
Sid
87ca5a85d0 chore: add a promise based wait group for the shell variables (#7647) 2026-04-01 20:41:30 +05:30
Pooja
40298b96a4 fix: preserve query params without values by not appending = sign (#7567)
* fix: preserve query params without values by not appending = sign

* fix: parseCurlCommand test
2026-04-01 20:08:31 +05:30
Sid
9e89255f6d security: fix all critical vuln dependency reports (#7645)
* chore: remove form-data vuln

* chore: stale aws in lock

* chore: other critical vulns

* chore: correct deps
2026-04-01 18:28:47 +05:30
Pooja
28d1ba2438 improve: graphql query builder test (#7618) 2026-04-01 17:11:22 +05:30
Sid
652f3cc3fe feat: basic annotation syntax support for lang (#7609)
* chore: basic annotation support

* chore: string and escape cases

* feat: add basic multiline support

* fix: simplify dedentation logic in annotation multiline text block

* chore: fix asserts

* feat: add annotation support to env and collection

* feat: enhance annotation parsing with support for quoted arguments and nested parentheses

* refactor: feedback, remove inline annotations

* feat: move serializeAnnotations function to utils and update imports
2026-04-01 16:04:34 +05:30
Pooja
aa7b8f4ca1 fix: autosave playwright test (#7641)
* fix: autosave playwright test

* fix
2026-04-01 15:06:31 +05:30
gopu-bruno
bcc1b535ff fix: prevent rerun flicker and fix runner configuration list order (#7639) 2026-04-01 14:20:08 +05:30
Chirag Chandrashekhar
ce105aea58 fix: refine dotenv serialization for special characters handling (#7592) 2026-04-01 13:10:36 +05:30
Chirag Chandrashekhar
8338f91487 fix: app crash on clicking close button (#7637)
* fix: app crash on clicking close button \n Added collection, workspace, and api spec watcher cleanup on app close method

* fix: close file watchers before app exit to prevent crash on macOS

Close all chokidar file watchers (collection, workspace, apiSpec) before
the Node environment is torn down. The native FSEvents watchers run on
their own threads and their cleanup races with FreeEnvironment, causing
an abort when fse_instance_destroy tries to lock a destroyed mutex.

Watchers are closed in both mainWindow.on('close') and app.on('before-quit')
to cover the native close button path and the app.exit() path.

* fix: move watcher cleanup from close handler to before-quit only

The close event is cancelable — if the user cancels the unsaved changes
dialog, watchers would remain closed for the rest of the session.
Move closeAllWatchers() to before-quit which only fires on actual quit.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-01 10:54:50 +05:30
Sid
4a78f637d3 fix: 'axios' module not found locally (#7638) 2026-03-31 20:14:08 +05:30
Sid
3b38b14362 fix: update keybinding actions and tests for reopening closed tabs (#7635) 2026-03-31 18:43:14 +05:30
Pooja
4f5c73840c fix: folder docs edit button style (#7630) 2026-03-31 16:39:14 +05:30
Sid
3ea489816c chore: pin axios version (#7632)
* chore: pin axios version

* limit it to the max mentioned by the repo

* chore: pin to 1.13.6

* chore: pin transitive

* chore: update axios version to 1.13.6

* chore: min release age for deps
2026-03-31 15:30:00 +05:30
shubh-bruno
f0866be3b3 feat: keybindings customisation (#7603) 2026-03-31 12:39:00 +05:30
Pooja
882b11ca3d fix: multipart form button alignment (#7629)
* fix: multipart form button alignment

* rm: styles
2026-03-31 11:42:31 +05:30
Sid
53aa9ed6e3 fix(dependencies): update fast-xml-parser to 5.5.7 and simple-git to … (#7602)
* fix(dependencies): update fast-xml-parser to 5.5.7 and simple-git to 3.32.3; add path-expression-matcher and fast-xml-builder
2026-03-30 19:20:50 +05:30
shubh-bruno
c01942a6f3 fix: status & statusText swap (#7589)
* fix: status & statusText swap

* chore: typo

* test: tests for swapping status and statusText

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-03-28 15:12:42 +05:30
Abhishek S Lal
f491e9091b refactor: update file name display in MultipartFormParams and ResponseExampleMultipartFormParams components (#7595)
- Replaced the static file name display with a SingleLineEditor for better readability and consistency.
- Removed unnecessary padding in StyledWrapper for a cleaner layout.
- Enhanced value interpolation logic to handle arrays in interpolate-vars.js for improved data processing.
2026-03-27 20:15:34 +05:30
Abhishek S Lal
2977fc7bea fix: coerce Postman header values to string during import (#7564)
Postman collections can contain numeric header values (e.g., status code 200),
which fail Bruno's schema validation expecting strings. Wrap header.value in
String() for example request and response headers, matching the existing
pattern used for regular request headers.
2026-03-27 20:08:36 +05:30
Ayush
d07c323755 prevent Enter key from submitting form during autocomplete selection (#7221) 2026-03-27 20:04:52 +05:30
Abhishek S Lal
f1b84e09c3 fix: update regex for path parameter parsing to handle alphanumeric and underscore characters (#7388) 2026-03-27 20:03:00 +05:30
Chirag Chandrashekhar
13e97f0367 fix: preserve global environment color during script execution (#7427)
When executing requests with pre-request or post-response scripts, the
global environment color property was being stripped from YAML files.
This happened because the save operation only passed `variables` through
the IPC chain, and the workspace-environments store created a new
environment object without preserving the existing `color` property.

The fix passes the `color` property through the entire IPC chain:
- Redux actions now include `color` in the save-global-environment IPC call
- IPC handler accepts and forwards `color` to both stores
- workspace-environments store includes `color` when creating the environment object
- global-environments store preserves `color` when updating

Fixes #7348

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-27 19:56:19 +05:30
Chirag Chandrashekhar
7ef3981656 fix: resolve theme, overflow, and z-index bugs in Remove Collection modal (#7590)
Use themed styled-component classes instead of hardcoded Tailwind colors
for the drafts confirmation modal, add text truncation for long collection
names, and lower EditableTable resize-handle z-index so it no longer
renders above modals.

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-27 19:52:57 +05:30
SahilShameerDev
d2f6eb146b fix: make documentation, folder docs and collection docs edit button … (#7151)
* fix: make documentation, folder docs and collection docs edit button sticky

* Update packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update packages/bruno-app/src/components/CollectionSettings/StyledWrapper.js

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

* Update packages/bruno-app/src/components/FolderSettings/StyledWrapper.js

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

* refactor: move sticky edit button styles to specific doc components

* style: clean up redundant css rules in markdown-body

* Update packages/bruno-app/src/components/CollectionSettings/Docs/StyledWrapper.js

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-27 19:44:59 +05:30
sanish chirayath
bef4b6bbee feat(cookies): add direct cookie access methods and update translations (#7073)
* feat(cookies): add direct cookie access methods and update translations

- Introduced new methods for direct cookie access: `bru.cookies.get`, `bru.cookies.has`, and `bru.cookies.toObject`.
- Updated translation mappings in `bruno-to-postman-translator` and `postman-to-bruno-translator` to support these new methods.
- Enhanced tests to verify correct translation between `bru` and `pm` cookie methods, including mixed usage scenarios.
- Updated `Bru` class to handle cookie access based on the current request URL.

* feat(cookies): enhance cookie management with new methods and refactor

- Added new cookie methods: `toString`, `clear`, `delete`, `one`, `all`, `idx`, `count`, `indexOf`, `find`, `filter`, `each`, `map`, and `reduce` to `bru.cookies`.
- Refactored `Bru` class to utilize a new `CookieList` for cookie management, improving structure and readability.
- Updated translation mappings in `bruno-to-postman-translator` and `postman-to-bruno-translator` to include new cookie methods.
- Introduced `PropertyList` and `ReadOnlyPropertyList` classes for better data structure management.
- Enhanced tests for comprehensive coverage of new cookie functionalities and their interactions.

* docs(readonly-property-list): clarify array usage in constructor comments

* feat(cookies): add direct cookie manipulation tests and methods

* feat(cookies): add hasCookie method for checking cookie existence

* fix

* refactor(cookies): simplify cookie method translations

* feat(cookies): expand cookie API with new methods and tests

- Added new cookie methods: `get`, `has`, `toString`, `clear`, `upsert`, `remove`, `idx`, and `indexOf` to enhance cookie management.
- Updated translation mappings for `bru.cookies` to include new methods in `bruno-to-postman-translator` and `postman-to-bruno-translator`.
- Introduced tests for new methods and their interactions, ensuring comprehensive coverage of cookie functionalities.
- Enhanced existing tests to validate correct behavior of cookie methods across different scenarios.

* refactor(cookies): update CookieList to extend PropertyList and improve error handling

* test(cookies): add regression tests for jar and direct cookie patterns

- Introduced regression tests to ensure that jar patterns are correctly prioritized over direct cookie access patterns in translations.
- Updated `CookieList` to extend `ReadOnlyPropertyList` instead of `PropertyList`, clarifying its functionality.
- Refactored cookie method handling in the `bru` shim to utilize a new asynchronous bridge for improved error handling and consistency.

* refactor(cookies): update translations and remove PropertyList

- Enhanced comments in `postman-translations.js` to clarify the order of cookie jar translations.
- Updated `cookie-list.js` comments to better describe the factory function for the cookie jar.
- Removed the `PropertyList` class and its associated tests, streamlining the codebase and focusing on `ReadOnlyPropertyList` and `CookieList` for cookie management.

* fix(cookies): normalize tough-cookie objects and improve remove method comments

- Updated `CookieList` to normalize tough-cookie instances to plain objects, preventing circular references and exposing internal structures.
- Enhanced comments in the `remove` method to clarify behavior when removing non-existent or empty-named cookies.

* test(cookies): update tests to use async/await for consistency

* test(cookies): use async/await in cookie tests for consistency

* refactor(readonly-property-list): update get and reduce methods for improved behavior

* fix(cookies): update cookie method signature in autocomplete hints and enhance translation comments

- Modified the autocomplete hint for `bru.cookies.has` to include the new signature with an optional value parameter.
- Improved comments in `postman-translations.js` to clarify the order of cookie jar translation patterns for better understanding.

* refactor(cookies): introduce PropertyList for enhanced cookie management

* refactor(property-list): simplify repopulate method and enhance item handling logic

* feat(cookies): implement PropertyList bridge for enhanced cookie management

- Introduced a new `createPropertyListBridge` utility to streamline the integration of cookie methods into the QuickJS VM.
- Replaced the previous async cookie bridge with a more flexible approach, allowing for both synchronous and asynchronous cookie operations.
- Added comprehensive tests to validate the functionality of the new cookie methods in both developer and safe modes.
- Updated existing cookie tests to ensure compatibility with the new PropertyList structure.

* fix(tests): correct expected passed requests in cookie tests

- Updated the expected number of passed requests in the cookie tests from 34 to 6 to reflect the correct validation results.
- Ensured consistency in test assertions across multiple test cases for the PropertyList API.

* fix(cookies): update cookie URLs to use localhost for testing

- Changed all cookie-related test scripts to use `{{localhost}}` instead of `{{host}}` for the ping URL, ensuring consistency in local testing environments.
- Updated the cookie test suite to reflect the new URL structure, enhancing the reliability of the tests.
- Removed outdated cookie test files to streamline the test suite.

* refactor(cookies): standardize cookie handling with localhost variable

- Updated cookie test scripts to utilize the `{{localhost}}` variable for setting and retrieving cookies, ensuring consistency across tests.
- Enhanced clarity in comments regarding cookie behavior for different domains.
- Improved test assertions to validate cookie management functionality more effectively.

* refactor(property-list, readonly-property-list): update methods to use private class fields

* feat(cookies): enhance CookieList API with detailed documentation and method improvements

- Updated the `CookieList` class to provide comprehensive documentation on cookie management methods, including `add`, `upsert`, `remove`, and `delete`.
- Improved method signatures to support both callback and Promise-based usage for asynchronous operations.
- Added detailed descriptions for read and write methods, including examples and expected behavior.
- Enhanced the integration of the `CookieList` with the QuickJS VM by updating the property list bridge to include `toJSON` in sync read object methods.

* feat(cookies): add detailed examples and improve async bridge documentation

- Enhanced the `createPropertyListBridge` function documentation with comprehensive examples for setting up cookie methods in QuickJS.
- Clarified the two-phase setup process for async write methods, detailing the registration of bridge functions and the generation of JavaScript code for method wrappers.
- Added a new test case for the `toJSON()` method to ensure it returns a cloned array of all cookies, validating the expected structure and properties.

* fix(assert-runtime): correct syntax error in response parser assignment

- Added a semicolon at the end of the response parser assignment to ensure proper syntax in the AssertRuntime class.
2026-03-27 19:42:23 +05:30
gopu-bruno
c2de480091 feat: revamp Runner UI with Timings and Filters sections (#7505)
* feat: revamp Runner UI with Timings and Filters sections

* fix: use configurable radio name for runner tags

* fix: update Run Collection modal ui

* refactor: improve runner radios accessibility and ux

* fix: address runner review nits

* fix: update tag list hover styling

* fix: add data-testid for runner button

* fix: preserve runner delay when updating request selection config

* fix: preserve runner requestItemsOrder on run

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
2026-03-27 19:38:36 +05:30
Sid
f5a9a485ed fix(security): santize HTML before being rendered in documentation blocks (#7598)
* fix: purify markdown before rendering

* chore: resolve stale html
2026-03-27 19:34:34 +05:30
lohit
95de14adcb feat: add OAuth 1.0 authentication support (#7482)
* feat: add OAuth 1.0 authentication support

Add full OAuth 1.0 (RFC 5849) authentication with support for
HMAC-SHA1/256/512, RSA-SHA1/256/512, and PLAINTEXT signature methods.
Includes UI components, bru/yml serialization, Postman import, code
generation, CLI support, and comprehensive playwright and unit tests.

* test: replace real-looking PEM literals with fake markers in oauth1 tests

Avoid tripping secret scanners by using obviously fake BEGIN/END markers
and non-sensitive base64 content in serialization and round-trip tests.

* fix: remove invalid OAuth1 placeholder header from code generator

OAuth1 requires runtime-computed nonce, timestamp, and signature that
cannot be pre-computed for a static code snippet. Return an empty array
instead of emitting an Authorization header with literal <signature>,
<timestamp>, <nonce> placeholders.

* fix: remove unreachable oauth1 case from WSAuth component

The oauth1 switch branch was dead code since it was not in
supportedAuthModes and the useEffect would reset it to 'none'
before it could render.

* fix: remove unused collectionPath param and use path.basename for filename extraction

* refactor: rename OAuth1 fields for clarity

- tokenSecret → accessTokenSecret
- signatureMethod → signatureEncoding
- addParamsTo value 'queryparams' → 'query'

* refactor: rename addParamsTo to placement in OAuth1 auth

* fix: add missing oauth1: null in buildOAuth2Config and upgrade @opencollection/types to 0.9.0

* test: add oauth1 import tests and fix missing oauth1: null in auth assertions

* ci: add auth playwright tests workflow for Linux, macOS, and Windows

* refactor: rename signatureEncoding to signatureMethod and fix timeline race condition

- Rename OAuth1 signatureEncoding to signatureMethod across all packages
- Fix timeline showing "No Headers/Body found" when request-sent IPC event
  arrives after response by retroactively updating the timeline entry
- Store requestUid in timeline entries for precise matching
- Correct timeline entry timestamp on retroactive update for proper sort order

* ci: add OAuth1 CLI tests and reorganize auth actions under oauth1/

- Add CLI tests that run full BRU and YML collections via bru run
- Add start-test-server actions for Linux, macOS, and Windows
- Move auth e2e and setup actions under auth/oauth1/ directory
- Fix Windows Playwright failures caused by unescaped backslashes in collectionPath template variable

* ci: reorder auth tests to run E2E tests before CLI tests

* ci: start test server after E2E tests to fix port 8081 conflict
2026-03-27 18:59:42 +05:30
sanish chirayath
784e851d4c refactor: update Bru constructor to accept a single options obj for improved readability (#7562)
* refactor: update Bru constructor to accept a single options object for improved readability

- Changed the Bru class constructor to accept a single options object instead of multiple parameters, enhancing code clarity and maintainability.
- Updated all instances of Bru instantiation across the codebase to align with the new constructor format.

* docs: enhance Bru constructor documentation with additional certs and proxy configuration options

- Updated the documentation for the Bru class constructor to include new parameters related to certs and proxy configuration, improving clarity for users on available options.
- Added descriptions for collectionPath, options, clientCertificates, collectionLevelProxy, and systemProxyConfig to provide comprehensive guidance on their usage.

* docs: refine Bru constructor documentation for clarity and default values

- Updated the constructor documentation for the Bru class to enhance clarity by consolidating parameter descriptions into a single options object format.
- Added default values for optional parameters, improving guidance for users on expected input and usage.
2026-03-27 18:56:09 +05:30
Abhishek S Lal
708e88241f style: update CodeMirror bracket highlighting in StyledWrapper components (#7596)
- Enhanced the styling for matching and non-matching brackets in CodeMirror across multiple StyledWrapper components.
- Updated background and text colors to align with the theme for better visibility and user experience.
2026-03-27 18:13:08 +05:30
Chirag Chandrashekhar
bbf3cb8dd3 fix: preserve user-defined boundary in multipart/mixed Content-Type header (#7531)
* fix: preserve user-defined boundary in multipart/mixed Content-Type header

When users specify a boundary parameter in their Content-Type header for
multipart/mixed requests with TEXT body mode, Bruno now preserves the
user-defined boundary instead of generating a new one.

Fixes: https://github.com/usebruno/bruno/issues/7523

* updated the test to use local server and changed the request method to GET

* fix: handle quoted boundary values in Content-Type header extraction

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-27 16:49:50 +05:30
Pooja
53b75d083f fix: multipart form upload icon visibility (#7571) 2026-03-27 16:41:36 +05:30
Pooja
9cea60477a fix: prompt variable in URL path incorrectly parsed as query parameter (#7216) 2026-03-27 15:47:21 +05:30
Pooja
35cd72534b feat: graphql query builder (#7468)
* feat: graphql query builder

* fix: bug

* improvements

* fix

* fix: playright test

* fix

* fix

* improvements

* chore: types

* fix

* chore: minimal error boundary

* imp: use button component

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2026-03-27 12:29:01 +05:30
Thomas
f69afd7fa2 fix: add the meta block to the object returned by transformFolderRootToSave (#7582)
Co-authored-by: fantpmas <fantpmas@users.noreply.github.com>
2026-03-26 20:41:31 +05:30
Chirag Chandrashekhar
ff975c44f2 fix: cross-collection drag and drop tab and format issues (#7584)
Close the open tab when a request is moved to a different collection
via drag and drop, preventing the "Request no longer exists" error.

Add format conversion when dragging requests between collections with
different formats (.bru vs .yml). A new IPC handler parses the source
file and re-serializes it in the target collection's format. Folder
cross-format moves are blocked with a toast error.

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-26 20:32:29 +05:30
Abhishek S Lal
03dcb6b7b9 feat: implement item sorting for Postman export (#7581)
- Added functions to sort items by sequence and name, ensuring folders are prioritized over requests in the export output.
- Enhanced the `brunoToPostman` function to utilize the new sorting logic.
- Introduced comprehensive tests to validate the sorting behavior for folders and requests, including nested structures.
2026-03-26 20:16:14 +05:30
Abhishek S Lal
c8d835ef4d fix: re-apply masking in MultiLineEditor and SingleLineEditor after setValue() to preserve CodeMirror marks (#7585) 2026-03-26 20:10:38 +05:30
Sid
9944819f73 chore: add in more react standards (#7577)
* chore: add in more react standards

* Update CODING_STANDARDS.md

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-26 14:56:42 +05:30
shubh-bruno
73df422c4e feat: persist window frames and widths (#7409) 2026-03-26 13:11:04 +05:30
shubh-bruno
304f6c8b80 feat: support for pkg installer (#7561)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-03-25 16:35:18 +05:30
Abhishek S Lal
4245944ccc fix(theme): convert theme bg to hex for Electron backgroundColor (#7569)
* fix(theme): convert theme bg to hex for Electron backgroundColor

* fix(theme): simplify background color conversion to hex in ThemeProvider
2026-03-25 15:30:24 +05:30
Abhishek S Lal
590a5a968d fix(import): handle EEXIST when importing OpenAPI collections with paths grouping (#7499)
Use { recursive: true } in mkdirSync during collection import so that
directories which already exist (e.g. due to duplicate or case-colliding
path params like {customerID} vs {customerId}) do not throw EEXIST and
abort the import.
2026-03-24 19:19:29 +05:30
statxc
650ad0fe60 fix: overlapping help text issue in Environment Variables (#7225)
* fix: overlaping help text

* fix: add tooltip-mod class to InfoTip in VarsTable to fix overlapping help text

* chore: fix width for request pane infotip

---------

Co-authored-by: statxc <statxc@users.noreply.github.com>
Co-authored-by: Ubuntu <ubuntu@vps-eae40731.vps.ovh.ca>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-03-24 18:05:59 +05:30
dependabot[bot]
367465b371 chore(deps): bump dorny/test-reporter from 2 to 3 (#7555)
Bumps [dorny/test-reporter](https://github.com/dorny/test-reporter) from 2 to 3.
- [Release notes](https://github.com/dorny/test-reporter/releases)
- [Changelog](https://github.com/dorny/test-reporter/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dorny/test-reporter/compare/v2...v3)

---
updated-dependencies:
- dependency-name: dorny/test-reporter
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 17:49:11 +05:30
sanish chirayath
7182cee629 fix: enhance error handling and context retrieval for script errors (#7537)
* refactor: enhance error handling and context retrieval for script errors

- Updated the error formatter to utilize in-memory script content for error context, improving accuracy when users have unsaved changes.
- Introduced a new utility function, `getSourceContextFromContent`, to extract context lines from in-memory scripts.
- Enhanced tests to verify that draft script errors display the correct code context, ensuring users see the most relevant information during debugging.
- Added new Playwright tests to validate error handling in draft states across pre-request, post-response, and test scripts.

* refactor: enhance error context retrieval in error formatter

- Updated `getSourceContext` to accept in-memory content, improving context extraction for unsaved changes.
- Deprecated `getSourceContextFromContent` in favor of the new parameterized approach.
- Adjusted related tests to ensure accurate context handling for draft script errors.

* refactor: enhance error context handling for draft scripts

* refactor: streamline script error tests and enhance utility functions

- Consolidated helper functions for sending requests and waiting for responses into the actions module.
- Introduced new utility functions for selecting script sub-tabs and editing CodeMirror editors.
- Updated test cases to utilize the new utility functions, improving readability and maintainability.
- Enhanced locators for better integration with testing frameworks.

* refactor: improve script error context handling and utility functions

- Introduced a new utility function to streamline the retrieval of script block start lines for .bru and .yml files.
- Enhanced the error formatter to prioritize in-memory draft content when resolving error contexts, improving accuracy for unsaved changes.
- Consolidated context extraction logic into a single function to reduce redundancy and improve maintainability.
- Updated related tests to ensure accurate context handling for both draft and disk-based scripts.

* refactor: add comments to clarify line index calculations in error formatter
2026-03-24 15:31:21 +05:30
sanish chirayath
86b6e2f4f3 feat: add hasCookie hint to autocomplete suggestions for cookie management (#7516) 2026-03-24 12:52:03 +05:30
Pooja
37c0a76146 fix: html report collapse for repeated requests (#7153) 2026-03-24 12:44:20 +05:30
Pooja
4461dfded3 fix: global search filter by active workspace (#7156) 2026-03-24 12:41:04 +05:30
Abhishek S Lal
123c2893f3 feat(theme): enhance theme management with background color support (#7454)
- Updated ThemeProvider to send background color along with theme changes to the renderer.
- Introduced WindowStateStore methods for managing theme mode and background color.
- Set the main window's background color based on the stored theme during app initialization.

This improves the user experience by ensuring the application reflects the selected theme accurately.
2026-03-24 12:35:09 +05:30
naman-bruno
f1d7f007fe remove activeEnvironmentUid and migration (#7545)
* remove activeEnvironmentUid and migration

* fix: no environment handling

* fix: standardize workspace path handling
2026-03-23 21:02:53 +05:30
Chirag Chandrashekhar
32b9f527ea fix: quote values containing hash (#) in .env file serialization (#7380)
* fix: quote values containing hash (#) in .env file serialization

Values containing # characters were being truncated when saved to .env
files because the dotenv parser interprets # as a comment character.

This fix adds a shared jsonToDotenv utility in bruno-common that properly
quotes values containing special characters (#, \n, ", ', \) to ensure
they are preserved through serialization and parsing.

- Add jsonToDotenv utility with comprehensive test coverage
- Update bruno-electron and bruno-app to use shared utility
- Remove duplicate serialization logic from multiple locations

Fixes https://github.com/usebruno/bruno/issues/7375
Fixes https://github.com/usebruno/bruno/issues/7327

* fix: escape carriage returns in .env file serialization for Windows CRLF handling

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-23 20:58:28 +05:30
Chirag Chandrashekhar
79f9dbff9f fix: handle nested parentheses in URL link detection (#7406)
The LinkifyIt library was truncating URLs containing nested parentheses,
such as Kibana/RISON formatted links. For example, a URL like:
https://example.com/?_g=(filters:!(),time:(from:now))&_a=(data)
would be cut off at the first balanced parenthesis, losing the &_a=...
portion.

Added extendUrlWithBalancedParentheses helper function that:
- Counts unbalanced opening parentheses in the detected URL
- Extends the URL to include closing parens and following content
- Stops at URL terminators (whitespace, quotes, angle brackets)
- Stops if parentheses would become over-balanced (more closing than opening)

Fixes #7402

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-23 20:42:56 +05:30
sanish chirayath
646c90819d feat: enhance ScriptError with source context and remove auto-commenting of untranslated pm commands (#7449)
* feat: enhance ScriptError with source context, code snippets, and navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: remove auto-commenting of untranslated pm commands during import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: update CodeSnippet styles to use theme colors for error and warning highlights

* fix: remove unused SCRIPT_TYPES import from network IPC module

* refactor: remove unused functions and clean up source-context utility

- Removed `getUnifiedScriptContext`, `getWarningSourceGroups`, and related helper functions from `source-context.js` to streamline the utility.
- Updated tests in `source-context.spec.js` to reflect the removal of unused functions, ensuring only relevant tests for `findLineInSource` and `getScriptContext` remain.

* refactor: simplify ScriptError component and update styles

* refactor: streamline tab management in Script components

* refactor: enhance tab management in ScriptError and add testsMetadata handling in prepare-request

* refactor: improve error source identification in ScriptError component

- Enhanced the logic for determining error source types by introducing separate checks for folder and collection files.
- Updated the handling of folder file names to ensure accurate UID retrieval and labeling.
- Streamlined the overall structure of the getErrorSourceInfo function for better readability and maintainability.

* refactor: improve ScriptError component and enhance styling

- Simplified the conditions for displaying the ScriptError component in ResponsePane.
- Updated navigation logic in ScriptErrorCard to ensure proper handling of source information.
- Adjusted styles in StyledWrapper to allow for visible overflow.
- Enhanced ScriptErrorIcon to accept additional class names for better styling flexibility.
- Minor layout adjustments in RunnerResults ResponsePane for improved UI consistency.

* refactor: simplify test description for untranslated pm commands and consolidate error formatter imports

* fixes

* refactor: update focusedTab logic in Script components to use collection and folder UIDthe respective collection and folder UID instead of the activeTabUid.

* feat: add buildErrorContext utility for enhanced error handling in network IPC

- Introduced a new utility function `buildErrorContext` to construct detailed error context from script errors.
- This function parses error locations, adjusts line numbers, and retrieves relevant source context, improving error reporting.
- Removed the previous inline error handling logic from the network IPC module to streamline the codebase.

* feat: added playwright test cases to test scriptError behavior

* refactor: enhance ScriptError component and improve error handling

- Updated labels for error source types in ScriptError to be more concise (e.g., "Request Script" to "Request").
- Improved navigation logic in ScriptErrorCard to ensure proper handling of navigable file paths.
- Enhanced styling in StyledWrapper for better visual consistency and user experience.
- Added tests to verify fallback behavior for missing error types and non-navigable file paths.
- Refined utility functions for better context extraction from script errors.

* refactor: normalize file paths for cross-platform compatibility in ScriptError component

* refactor: improve file path handling in ScriptError component

* refactor: enhance buildErrorContext for improved error handling

* docs: add detailed comments to build-error-context for better understanding of error handling

* refactor: enhance error block line detection in error formatter

- Updated `findScriptBlockEndLine` and `findYmlScriptBlockEndLine` functions to return null for empty or missing blocks, improving accuracy in line detection.
- Added comprehensive tests for both functions to ensure correct behavior across various scenarios, including handling of non-.bru and non-.yml files.

* refactor: improve error handling and testing in ScriptError and buildErrorContext

- Updated ScriptError component to streamline tab management by replacing focusTab with addTab for better request handling.
- Enhanced buildErrorContext to return null for empty or missing script blocks in .bru and .yml files, ensuring accurate error reporting.
- Added tests to validate behavior for empty script blocks and improved error context extraction in various scenarios.

* test: add new tests for script error navigation and handling

- Implemented tests for post-response file-path navigation to the Script tab and verification of active sub-tabs.
- Added keyboard navigation tests to trigger file-path navigation using the Enter key.
- Included a test for multiple error cards to ensure closing one does not affect others.
- Enhanced runner tests to verify navigation to the Tests tab from script error results.

* refactor: enhance CodeSnippet line rendering and remove unused source-context utilities

* refactor: update locators in script-errors tests for improved readability and maintainability

* test: enhance script-errors tests to verify error line content

* review fixes

* refactor: update RunnerResults component and enhance locators for improved testability

* refactor: remove buildErrorContext and replace with formatErrorWithContextV2

- Deleted the buildErrorContext function and its associated tests.
- Updated network IPC to utilize formatErrorWithContextV2 for improved error context handling.
- Enhanced error reporting by ensuring structured error context is returned for desktop UI.

* refactor: enhance tab components with data-testid attributes for improved testability

- Added data-testid attributes to tab elements in CollectionSettings and FolderSettings components for better integration with testing frameworks.
- Updated Tabs and ResponsiveTabs components to include data-testid attributes for tab triggers, enhancing the ability to select and verify tabs in tests.
- Modified script-errors tests to utilize new locators for improved readability and maintainability.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 21:36:02 +05:30
Pragadesh-45
37be721922 feat: include pinned data in filtering for more accurate results in env variables search (#7513) 2026-03-18 17:56:25 +05:30
naman-bruno
d6429cbb6d fix: clear draft on save and update dependencies in useEffect (#7512) 2026-03-18 14:49:07 +05:30
naman-bruno
0109d72475 refactor: optimize formik value handling and improve save conditions (#7507)
* refactor: optimize formik value handling and improve save conditions

* fix
2026-03-18 14:27:58 +05:30
sanish chirayath
68d80b8f78 feat(bruno-js): add hasCookie function to cookie jar shim for improved cookie management (#7501) 2026-03-16 23:17:54 +05:30
Abhishek S Lal
1877119b81 fix(openapi-sync): simplify IPC calls, fix state priorities, and improve stored spec missing UX (#7489)
* refactor(OpenAPISyncTab): remove unused props and streamline IPC calls

- Eliminated unnecessary sourceUrl prop from various components and hooks in the OpenAPISyncTab.
- Improved pretty-printing logic in OpenAPISpecTab to handle non-JSON content gracefully.
- Updated IPC calls to remove redundant parameters, enhancing code clarity and maintainability.

* feat(OpenAPISyncTab): enhance user interaction and visual feedback

- Added onTabSelect prop to OpenAPISyncTab for improved tab navigation.
- Updated color properties in StyledWrapper for better consistency with theme.
- Replaced IconClock with IconAlertTriangle in CollectionStatusSection for clearer status indication.
- Enhanced messaging in OverviewSection and SpecStatusSection to provide clearer user guidance.
- Introduced handleRestoreSpec function in useSyncFlow for better spec restoration handling.

* fix(OpenAPISyncTab): update button labels for clarity in OverviewSection

- Changed button label from 'restore' to 'spec-details' for better context.
- Updated the button text from 'View Details' to 'Go to Spec Updates' to enhance user understanding of navigation options.

* refactor(OpenAPISyncTab): remove unused props and streamline component logic

- Eliminated unnecessary props from OpenAPISyncTab, CollectionStatusSection, and SpecStatusSection for cleaner code.
- Removed commented-out code in OverviewSection and SpecStatusSection to enhance readability.
- Introduced posixifyPath utility function in filesystem.js to standardize path formatting.

* fix(OpenAPISyncTab): update openapi config handling to support array format

- Modified the logic in loadBrunoConfig to handle openapi as an array, ensuring consistent resolution of source URLs for all entries. This change improves the configuration handling for OpenAPI specifications.

* fix(OpenAPISyncTab): improve openapi config handling and merge logic

- Updated loadBrunoConfig to ensure openapi is treated as an array, enhancing source URL resolution.
- Modified mergeWithUserValues to handle cases where specItems may be undefined, improving robustness in merging user values with specifications.
2026-03-16 18:14:53 +05:30
Chirag Chandrashekhar
7e717768d2 fix(RequestTabPanel): update loading message for better user feedback (#7492)
Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-16 16:59:06 +05:30
naman-bruno
c1dff11fa1 refactor: optimize debounced save functionality (#7495) 2026-03-16 16:47:27 +05:30
Pooja
7e0b8d9f9d fix: convert non-string variable values to strings during postman import (#7476) 2026-03-16 12:05:24 +05:30
Abhishek S Lal
83ddfc33d2 refactor(OpenAPISyncTab): streamline component logic and enhance user feedback (#7483)
- Removed unused props and improved error handling in OpenAPISyncTab components.
- Updated messaging in CollectionStatusSection and OverviewSection for clarity.
- Enhanced the SpecDiffModal to provide better visual feedback on changes.
- Refactored sync flow logic to ensure accurate endpoint categorization and improved performance.
- Added new utility functions for better handling of spec changes and endpoint comparisons.
2026-03-14 01:15:10 +05:30
Bohdan
1ab296f1e3 add missing color to scrollbar-color property (#7481) 2026-03-13 23:51:44 +05:30
Abhishek S Lal
384bf4f190 feat: improve OpenAPI Sync tab UX and fix sync flow bugs (#7467)
* fix: specify OpenAPI 3.x in error messages for file uploads and URL validation

Updated error messages in ConnectSpecForm and ConnectionSettingsModal to clarify that only OpenAPI 3.x specifications are valid. Enhanced useOpenAPISync hook to reflect the same specificity in error handling for invalid URLs.

* feat(OpenAPISpecTab): add pretty-printing for JSON content in API spec viewer

Implemented a new function to pretty-print JSON content for improved readability in the OpenAPISpecTab component. This enhancement ensures that JSON specifications are displayed in a more user-friendly format while leaving YAML content unchanged.

* feat(OpenAPISyncHeader): resolve and display absolute file paths for local sources

Added functionality to resolve relative file paths to absolute paths for better user experience in the OpenAPISyncHeader component. Implemented state management and side effects to handle path resolution based on the source URL, enhancing the display of local file paths.

* feat(OpenAPISyncTab): enhance collection status display and add endpoint counting utility

Refactored the CollectionStatusSection to streamline the display of collection drift status, integrating loading states and improved messaging for initial sync scenarios. Introduced a new utility function to count HTTP endpoints in OpenAPI specifications, enhancing the overall functionality of the OpenAPISyncTab. Additionally, updated the OpenAPISyncHeader and OverviewSection to utilize stored specification metadata for better user experience.

* refactor: improve OpenAPI Sync endpoint handling

- Enhanced the logic for adding new requests by ensuring existing files are verified before removal to prevent accidental deletions.
- Streamlined the process of adding new endpoints, including checks for existing files and merging requests to maintain user customizations.
- Added comments for clarity on the purpose of changes, particularly regarding filename collision prevention and file content verification.

* style(OpenAPISyncTab): update styles for improved visual feedback

- Changed background color for the 'type-spec-modified' class to a warning color for better distinction.
- Updated text color and background for the SyncReviewPage to enhance readability and visual hierarchy.
- Adjusted default expanded states for endpoint sections to improve user experience during sync reviews.

* chore: update .gitignore and enhance OpenAPISyncTab components

- Added new entries to .gitignore for agent-related files and skills-lock.json.
- Modified StyledWrapper to improve overflow handling and added sticky headers for better visibility.
- Introduced loading state in SpecDiffModal with a spinner for improved user feedback during rendering.

* feat(OpenAPISpecTab): integrate fast-json-format for improved JSON rendering

- Replaced the JSON parsing and stringifying logic with fast-json-format for better performance in pretty-printing API specifications.
- Updated StyledWrapper in OpenAPISyncTab to change background and text colors for enhanced visual consistency.
- Modified DisconnectSyncModal button to include a secondary color for improved visibility during user interactions.

* fix(OpenAPISyncTab): correct punctuation in status messages and subtitles

- Removed unnecessary trailing periods in messages related to syncing and restoring specifications across CollectionStatusSection and OverviewSection components.
- Updated SyncReviewPage to correct grammatical error in the description of spec updates.

* fix(OpenAPISyncTab): update URL validation to use isHttpUrl

- Replaced isValidUrl with isHttpUrl in ConnectSpecForm and ConnectionSettingsModal components to ensure only valid HTTP URLs are accepted.
- Updated the logic for enabling the save button based on the new URL validation method.

* fix(OpenAPISyncTab): normalize source URL before validation

- Trimmed the source URL in ConnectionSettingsModal to ensure consistent validation with isHttpUrl.
- Updated state initialization for URL and filePath to use the normalized source URL, improving handling of user input.
2026-03-13 23:48:46 +05:30
Abhishek S Lal
1e25825e74 fix(collection-watcher): prevent crash when deleting collections (#7470)
* fix(collection-watcher): guard against events firing after collection deletion

When deleting an OpenAPI-synced collection, saveBrunoConfig() writes to
bruno.json which creates buffered chokidar events (80ms stabilityThreshold).
If the collection directory is removed before those events fire,
getCollectionFormat() throws "No collection configuration found" for each
.bru file in the collection.

Add fs.existsSync(collectionPath) guards in the change, unlink, and
unlinkDir handlers to bail out early when the collection root no longer exists.

* fix(workspaces): ensure collection watcher stops before deletion

Added logic to remove the collection from the watcher when deleting files, preventing chokidar from firing events on a directory that is being removed. This change enhances stability during collection deletions by ensuring the watcher is properly managed.

* refactor(workspaces): remove redundant collection watcher logic during deletion

Eliminated the logic for stopping the collection watcher before deletion, streamlining the action for removing collections from workspaces. This change simplifies the code and maintains functionality without unnecessary complexity.

* test(collection): add integration test for collection deletion functionality

Introduced a new test suite to verify the deletion of collections from the workspace overview. The test ensures that collections are properly removed from both the UI and the file system, confirming the absence of any uncaught errors during the deletion process. This addition enhances the test coverage for collection management features.

* feat(collection): implement deleteCollectionFromOverview utility function

Added a new utility function to delete a collection directly from the workspace overview page. This function encapsulates the steps required to navigate the UI, confirm deletion, and ensure the collection is removed from both the interface and the file system. Updated the corresponding test to utilize this new function, enhancing code reusability and test clarity.

* fix(collection-watcher): add guards for collection path existence and error handling

Enhanced the unlink and unlinkDir functions to check for the existence of the collection path before proceeding. Added error handling for the getCollectionFormat function to prevent crashes when the collection format cannot be retrieved. These changes improve stability and robustness during collection deletion operations.
2026-03-13 23:47:09 +05:30
Thomas
994d51b680 feat: remove .bru reference in error message (#7479)
Co-authored-by: Thomas Vackier <thomas.vackier@inthepocket.com>
2026-03-13 22:38:09 +05:30
naman-bruno
ab18a6ba84 feat: integrate deferred loading for saving state in DotEnvFileEditor (#7463) 2026-03-13 17:14:23 +05:30
Sid
a8542c7312 Replace SpaceX external API with local graphql-yoga mock server (#7471)
* chore: switch to locally hosted graphql server

* chore: additional graphql check

* chore: error handling
2026-03-13 16:00:08 +05:30
naman-bruno
ab8a730bc3 feat: implement temporary workspace creation and confirmation flow (#7462)
* feat: implement temporary workspace creation and confirmation flow

* fixes
2026-03-13 12:24:45 +05:30
Abhishek S Lal
c0a2d74789 Feat/openapi sync beta tag (#7461)
* feat: introduce OpenAPI Sync beta feedback feature

- Added a feedback section in the OpenAPISyncTab and ConnectSpecForm to encourage user input during the beta phase.
- Styled the feedback message and button for better visibility.
- Updated the beta features list to include OpenAPI Sync and adjusted related components to reflect its beta status with appropriate badges.
- Enhanced the StatusBadge component to support a new 'xs' size for better integration in various UI elements.

* feat: integrate OpenAPI Sync beta feature toggle

- Updated the ImportCollectionLocation component to conditionally enable the "Check for Spec Updates" option based on the OpenAPI Sync beta feature status.
- Modified default preferences to disable OpenAPI Sync by default, ensuring users are not prompted for updates unless explicitly enabled.

* feat: enhance beta features integration in Preferences

- Updated the BETA_FEATURES array to use constants from utils/beta-features for better maintainability.
- Improved the handling of beta preferences by merging new preferences with existing ones, ensuring a smoother user experience when toggling features.

* feat: enhance OpenAPI Sync polling with beta feature toggle

- Integrated a beta feature toggle for OpenAPI Sync polling, allowing conditional activation based on user settings.
- Updated the pollingEnabled logic to incorporate the new beta feature status, ensuring better control over sync behavior.
2026-03-13 11:29:04 +05:30
Pragadesh-45
b25b6f36bb refactor: simplify environment list actions and improve styling (#7459) 2026-03-12 21:41:11 +05:30
Sid
670f11be37 revert: feat(phase-1): allow user to customize keybindings#7163 (#7457)
* Revert "feat(phase-1): allow user to customize keybindings (#7163)"

This reverts commit 14532b48a6.

* Revert "chore: UI Polish for Zoom and Keybindings panel (#7376)"

This reverts commit 5151d29aac.
2026-03-12 20:48:16 +05:30
naman-bruno
ddb1c69fc9 Revert "workspace renaming with path update (#7437)" (#7455)
This reverts commit e7c2c7c872.
2026-03-12 18:40:52 +05:30
William Rodrigues
6f6a9100e9 removed button changed possition to make more acessibility (#7341) 2026-03-12 13:10:12 +05:30
lohit
7c58740c74 fix: cookie wrapper callback mode returns never-resolving Promise (#7442)
* fix: cookie wrapper callback mode returns never-resolving Promise

tough-cookie's createPromiseCallback() intentionally never resolves the
returned Promise when a callback is provided — only the callback fires.
The cookie jar wrapper was propagating this never-resolving Promise via
`return cookieJar.getCookies(url, cb)` in callback-mode paths. When user
scripts did `await jar.getCookie(url, name, callback)` in the Node VM
(developer sandbox), the await hung forever, blocking the CLI runner.

Fix: drop the return value in all callback-mode paths so the wrapper
returns void (undefined). `await undefined` resolves immediately.

Affected methods: getCookie, getCookies, hasCookie, clear, deleteCookies.

* fix: validation-error callback paths also return void instead of callback result

The validation guards (e.g. missing URL) did `return callback(error)` which
leaks whatever the user's callback returns. Apply the same pattern used for
the main callback paths: call the callback, then return void.

Also makes deleteCookie's `return executeDelete(callback)` consistent
(executeDelete already returns void, but the explicit pattern is clearer).
2026-03-11 20:56:09 +05:30
naman-bruno
e7c2c7c872 workspace renaming with path update (#7437)
* workspace renaming with path update

* fixes

* update: test

* fix: test
2026-03-11 19:07:38 +05:30
naman-bruno
f6ff8efabe refactor: update path imports to use utils/common/path (#7440) 2026-03-11 19:05:42 +05:30
Pooja
ce8775c75c fix: multipart header check (#7444)
* fix: multipart header check

* fix
2026-03-11 19:01:14 +05:30
Chirag Chandrashekhar
803b3f0d1f fix: normalize paths when comparing workspace and redux collection paths on Windows (#7436)
Without path normalization, collections appear stuck in "mounting" state on Windows
because workspace YAML uses forward slashes while Redux uses backslashes.

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-11 16:13:06 +05:30
Nizam Chaudhary
9bdd439472 feat(request-pane): restore body tab scroll position on tab switch (#7250)
* feat(request-pane): restore body tab scroll position on tab switch

When editing large request bodies (JSON/XML/text/sparql), switching to
another tab (params, headers, auth, etc.) and back would reset the
CodeMirror editor scroll position to the top.

Fix by persisting the scroll position to Redux on editor unmount (via
CodeEditor's onScroll prop) and restoring it on mount (via initialScroll),
mirroring the existing scroll restoration pattern in QueryResultPreview.

* test: add playwright tests for body scroll restoration

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
2026-03-10 19:01:55 +05:30
557 changed files with 42903 additions and 12123 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno

View File

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

View File

@@ -0,0 +1,30 @@
name: 'Run OAuth1 CLI Tests - Linux'
description: 'Run OAuth1 CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to BRU test collection directory
cd tests/auth/oauth1/fixtures/collections/bru
echo "=== BRU Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
- name: Run YML format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to YML test collection directory
cd tests/auth/oauth1/fixtures/collections/yml
echo "=== YML Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-yml.xml --format junit

View File

@@ -0,0 +1,15 @@
name: 'Setup Auth Feature Dependencies - Linux'
description: 'Setup feature-specific dependencies for auth tests on Linux'
runs:
using: 'composite'
steps:
- name: Install additional OS dependencies for auth tests
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
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,16 @@
name: 'Start Test Server - Linux'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Linux'
runs:
using: 'composite'
steps:
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd packages/bruno-tests
echo "starting test server in background"
node src/index.js &
echo "server started with PID: $!"

View File

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

View File

@@ -0,0 +1,30 @@
name: 'Run OAuth1 CLI Tests - macOS'
description: 'Run OAuth1 CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to BRU test collection directory
cd tests/auth/oauth1/fixtures/collections/bru
echo "=== BRU Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-bru.xml --format junit
- name: Run YML format CLI tests
shell: bash
run: |
set -euo pipefail
BRU_CLI="../../../../../../packages/bruno-cli/bin/bru.js"
# navigate to YML test collection directory
cd tests/auth/oauth1/fixtures/collections/yml
echo "=== YML Format Collection Run ==="
node $BRU_CLI run --env Local --output junit-yml.xml --format junit

View File

@@ -0,0 +1,16 @@
name: 'Start Test Server - macOS'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on macOS'
runs:
using: 'composite'
steps:
- name: Start test server
shell: bash
run: |
set -euo pipefail
cd packages/bruno-tests
echo "starting test server in background"
node src/index.js &
echo "server started with PID: $!"

View File

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

View File

@@ -0,0 +1,34 @@
name: 'Run OAuth1 CLI Tests - Windows'
description: 'Run OAuth1 CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Run BRU format CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
# navigate to BRU test collection directory
Set-Location tests\auth\oauth1\fixtures\collections\bru
Write-Host "=== BRU Format Collection Run ==="
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-bru.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
if ($process.ExitCode -ne 0) { exit 1 }
- name: Run YML format CLI tests
shell: pwsh
run: |
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$BRU_CLI = "..\..\..\..\..\..\packages\bruno-cli\bin\bru.js"
# navigate to YML test collection directory
Set-Location tests\auth\oauth1\fixtures\collections\yml
Write-Host "=== YML Format Collection Run ==="
$process = Start-Process -FilePath "node" -ArgumentList "$BRU_CLI run --env Local --output junit-yml.xml --format junit" -NoNewWindow -Wait -PassThru -RedirectStandardError "nul"
if ($process.ExitCode -ne 0) { exit 1 }

View File

@@ -0,0 +1,14 @@
name: 'Start Test Server - Windows'
description: 'Start the bruno-tests mock server for OAuth1 CLI tests on Windows'
runs:
using: 'composite'
steps:
- name: Start test server
shell: pwsh
run: |
Set-StrictMode -Version Latest
Set-Location packages\bruno-tests
Write-Host "starting test server in background"
Start-Process -FilePath "node" -ArgumentList "src\index.js" -PassThru -WindowStyle Hidden

79
.github/workflows/auth-tests.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Auth Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
oauth1-tests-for-linux:
name: OAuth 1.0 Auth Tests - Linux
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/auth/oauth1/linux/setup-feature-specific-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/linux/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/linux/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/linux/run-oauth1-cli-tests
oauth1-tests-for-macos:
name: OAuth 1.0 Auth Tests - macOS
timeout-minutes: 60
runs-on: macos-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/macos/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/macos/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/macos/run-oauth1-cli-tests
oauth1-tests-for-windows:
name: OAuth 1.0 Auth Tests - Windows
timeout-minutes: 60
runs-on: windows-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/windows/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/windows/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/windows/run-oauth1-cli-tests

View File

@@ -73,7 +73,7 @@ jobs:
- name: Post PR comment
if: hashFiles('pr-comment.md') != ''
uses: actions/github-script@v7
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');

View File

@@ -43,7 +43,7 @@ jobs:
bru run --env Prod --output junit.xml --format junit --sandbox developer
- name: Publish Test Report
uses: dorny/test-reporter@v2
uses: dorny/test-reporter@v3
if: success() || failure()
with:
name: Test Report

3
.gitignore vendored
View File

@@ -51,6 +51,9 @@ bruno.iml
.cursor
.claude
.codex
.agents
.agent
skills-lock.json
# Playwright
/blob-report/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
min-release-age=10

View File

@@ -75,6 +75,8 @@ Remember, these rules are here to make our codebase harmonious. If something doe
- Avoid: `import * as React from "react";` then `React.useCallback(...)`
- Add `data-testid` to testable elements for Playwright
- Co-locate utilities that are truly component-specific next to the component, otherwise place shared items under a common folder
- Avoid mixed controlled and uncontrolled state in React components. A component is either controlled or uncontrolled. State needs a single source of truth instead of being computed by props and then recomputed internally.
- SHOULD: Use derived state variables instead of adding unneeded `React.useState` / `useState` hooks.
## Readability and Abstractions

View File

@@ -324,7 +324,7 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
await page.getByRole('button', { name: 'Create' }).click();
// Execute request
await page.locator('#send-request').getByRole('img').nth(2).click();
await page.getByTestId('send-arrow-icon').click();
// Verify response
await expect(page.getByRole('main')).toContainText('200 OK');

View File

@@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([
}
},
rules: {
'no-undef': 'error'
'no-undef': 'error',
'no-case-declarations': 'error'
}
},
{

10537
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.8.0",
"@opencollection/types": "0.9.1",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -37,7 +37,7 @@
"@typescript-eslint/parser": "^8.39.0",
"concurrently": "^8.2.2",
"cross-env": "10.1.0",
"eslint": "^9.26.0",
"eslint": "^9.39.4",
"eslint-plugin-diff": "^2.0.3",
"fs-extra": "^11.1.1",
"globals": "^16.1.0",
@@ -82,6 +82,7 @@
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test --project=default",
"test:e2e:ssl": "playwright test --project=ssl",
"test:e2e:auth": "playwright test --project=auth",
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",
"prepare": "husky"
@@ -92,7 +93,9 @@
]
},
"overrides": {
"rollup": "3.29.5",
"axios":"1.13.6",
"rollup": "3.30.0",
"pbkdf2":"3.1.5",
"electron-store": {
"conf": {
"json-schema-typed": "8.0.1"
@@ -103,4 +106,4 @@
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
}
}
}

View File

@@ -1,4 +1,4 @@
{
"presets": ["@babel/preset-env", "@babel/preset-react"],
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
"plugins": [["styled-components", { "ssr": true }]]
}

View File

@@ -1,8 +1,7 @@
module.exports = {
rootDir: '.',
transform: {
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
'^.+\\.[jt]sx?$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'

View File

@@ -1,8 +0,0 @@
const babelJest = require('babel-jest')
module.exports = {
process(sourceText, sourcePath, options) {
const transformer = babelJest.createTransformer();
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
}
};

View File

@@ -39,7 +39,7 @@
"github-markdown-css": "^5.2.0",
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"graphql-request": "4.2.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
@@ -100,9 +100,10 @@
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.22.0",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-node-polyfill": "1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",

View File

@@ -61,6 +61,17 @@ const StyledWrapper = styled.div`
.cm-variable-invalid {
color: ${(props) => props.theme.codemirror.variable.invalid};
}
.CodeMirror-matchingbracket {
background: ${(props) => props.theme.status.success.background} !important;
text-decoration: unset;
}
.CodeMirror-nonmatchingbracket {
color: ${(props) => props.theme.colors.text.danger} !important;
background: ${(props) => props.theme.status.danger.background} !important;
text-decoration: unset;
}
`;
export default StyledWrapper;

View File

@@ -57,16 +57,6 @@ export default class CodeEditor extends React.Component {
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent',
'Cmd-H': 'replace',

View File

@@ -160,7 +160,6 @@ const AppTitleBar = () => {
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}

View File

@@ -151,8 +151,14 @@ const StyledWrapper = styled.div`
//matching bracket fix
.CodeMirror-matchingbracket {
background: #5cc0b48c !important;
text-decoration:unset;
background: ${(props) => props.theme.status.success.background} !important;
text-decoration: unset;
}
.CodeMirror-nonmatchingbracket {
color: ${(props) => props.theme.colors.text.danger} !important;
background: ${(props) => props.theme.status.danger.background} !important;
text-decoration: unset;
}
.cm-search-line-highlight {

View File

@@ -16,7 +16,6 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
const CodeMirror = require('codemirror');
@@ -47,9 +46,6 @@ export default class CodeEditor extends React.Component {
this.state = {
searchBarVisible: false
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -78,26 +74,6 @@ export default class CodeEditor extends React.Component {
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': (cm) => {
this.setState({ searchBarVisible: true }, () => {
this.searchBarRef.current?.focus();
@@ -222,8 +198,11 @@ export default class CodeEditor extends React.Component {
// Setup lint error tooltip on line number hover
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
// Setup keyboard shortcuts
this._shortcutsCleanup = setupShortcuts(editor, this);
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
const cmInput = editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
}
}
@@ -240,18 +219,10 @@ export default class CodeEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
}
if (this.editor) {
@@ -295,12 +266,6 @@ export default class CodeEditor extends React.Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this.editor);

View File

@@ -0,0 +1,61 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.code-snippet {
font-family: monospace;
font-size: ${(props) => props.theme.font.size.xs};
line-height: 1.4;
overflow-x: auto;
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => props.theme.background.elevated};
border: 1px solid ${(props) => props.theme.border.border2};
}
.code-line {
display: flex;
align-items: stretch;
}
.code-line.highlighted-error {
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.danger};
}
.code-line.highlighted-warning {
background-color: ${(props) => rgba(props.theme.colors.text.warning, 0.1)};
border-left: 3px solid ${(props) => props.theme.colors.text.warning};
}
.code-line:not(.highlighted-error):not(.highlighted-warning) {
border-left: 3px solid transparent;
}
.code-line-number {
min-width: 2.5rem;
text-align: right;
padding: 0 0.5rem;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
flex-shrink: 0;
}
.code-line-content {
white-space: pre;
padding: 0 0.5rem;
flex: 1;
min-width: 0;
}
.code-line-separator {
border-left: 3px solid transparent;
}
.separator-content {
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
padding: 0 0.5rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,55 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const renderLine = (line, highlightClass, hunkIdx) => {
const isHighlighted = line.isHighlighted || line.isError;
const key = hunkIdx != null ? `${hunkIdx}-${line.lineNumber}` : line.lineNumber;
return (
<div
key={key}
className={`code-line ${isHighlighted ? highlightClass : ''}`}
data-testid={isHighlighted ? 'code-line-error' : 'code-line'}
>
<span className="code-line-number">{line.lineNumber}</span>
<span className="code-line-content">
{isHighlighted ? '> ' : ' '}{line.content}
</span>
</div>
);
};
const CodeSnippet = ({ lines, hunks, variant = 'error' }) => {
const highlightClass = variant === 'warning' ? 'highlighted-warning' : 'highlighted-error';
if (hunks?.length) {
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{hunks.map((hunk, idx) => (
<React.Fragment key={idx}>
{hunk.hasSeparatorBefore && (
<div className="code-line code-line-separator">
<span className="code-line-number"></span>
<span className="code-line-content separator-content">{'\u22EE'}</span>
</div>
)}
{hunk.lines.map((line) => renderLine(line, highlightClass, idx))}
</React.Fragment>
))}
</div>
</StyledWrapper>
);
}
if (!lines?.length) return null;
return (
<StyledWrapper>
<div className="code-snippet" data-testid="code-snippet">
{lines.map((line) => renderLine(line, highlightClass))}
</div>
</StyledWrapper>
);
};
export default CodeSnippet;

View File

@@ -0,0 +1,140 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import CodeSnippet from './index';
const theme = {
font: { size: { xs: '0.75rem' } },
background: { elevated: '#f5f5f5' },
border: { border2: '#e0e0e0', radius: { base: '4px' } },
colors: { text: { danger: '#ef4444', warning: '#f59e0b', muted: '#999' } }
};
const renderWithTheme = (component) => {
return render(
<ThemeProvider theme={theme}>
{component}
</ThemeProvider>
);
};
const sampleLines = [
{ lineNumber: 3, content: 'const a = 1;', isHighlighted: false },
{ lineNumber: 4, content: 'undefinedVar.foo();', isHighlighted: true },
{ lineNumber: 5, content: 'const b = 2;', isHighlighted: false }
];
describe('CodeSnippet', () => {
it('should render nothing when lines is empty', () => {
const { container } = renderWithTheme(<CodeSnippet lines={[]} />);
expect(container.firstChild).toBeNull();
});
it('should render nothing when lines is null', () => {
const { container } = renderWithTheme(<CodeSnippet lines={null} />);
expect(container.firstChild).toBeNull();
});
it('should render all lines with line numbers', () => {
renderWithTheme(<CodeSnippet lines={sampleLines} />);
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument();
});
it('should apply error highlight class by default', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="error" />);
const highlightedLine = container.querySelector('.highlighted-error');
expect(highlightedLine).toBeInTheDocument();
});
it('should apply warning highlight class when variant is warning', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} variant="warning" />);
const highlightedLine = container.querySelector('.highlighted-warning');
expect(highlightedLine).toBeInTheDocument();
expect(container.querySelector('.highlighted-error')).not.toBeInTheDocument();
});
it('should show > prefix on highlighted line for accessibility', () => {
const { container } = renderWithTheme(<CodeSnippet lines={sampleLines} />);
const codeLineContents = container.querySelectorAll('.code-line-content');
// The highlighted line (index 1) should start with "> "
expect(codeLineContents[1].textContent).toContain('> ');
// Non-highlighted lines should not have ">"
expect(codeLineContents[0].textContent).not.toContain('>');
});
it('should also support isError property for backward compatibility', () => {
const linesWithIsError = [
{ lineNumber: 1, content: 'line 1', isError: false },
{ lineNumber: 2, content: 'error line', isError: true },
{ lineNumber: 3, content: 'line 3', isError: false }
];
const { container } = renderWithTheme(<CodeSnippet lines={linesWithIsError} />);
expect(container.querySelector('.highlighted-error')).toBeInTheDocument();
});
describe('hunks prop', () => {
const sampleHunks = [
{
hasSeparatorBefore: false,
lines: [
{ lineNumber: 1, content: 'const a = true;', isHighlighted: false },
{ lineNumber: 2, content: 'pm.vault.get();', isHighlighted: true },
{ lineNumber: 3, content: 'const b = false;', isHighlighted: false }
]
},
{
hasSeparatorBefore: true,
lines: [
{ lineNumber: 10, content: 'const x = null;', isHighlighted: false },
{ lineNumber: 11, content: 'pm.cookies.jar();', isHighlighted: true },
{ lineNumber: 12, content: 'const y = undefined;', isHighlighted: false }
]
}
];
it('should render all lines from all hunks', () => {
renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
// line numbers
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('11')).toBeInTheDocument();
// content
expect(screen.getByText(/const a = true;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.vault\.get\(\);/)).toBeInTheDocument();
expect(screen.getByText(/const x = null;/)).toBeInTheDocument();
expect(screen.getByText(/pm\.cookies\.jar\(\);/)).toBeInTheDocument();
});
it('should render separator between hunks when hasSeparatorBefore is true', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separators = container.querySelectorAll('.code-line-separator');
expect(separators).toHaveLength(1);
// separator should appear between the two hunks, not before the first
const allRows = container.querySelectorAll('.code-line, .code-line-separator');
const separatorIndex = Array.from(allRows).findIndex((el) => el.classList.contains('code-line-separator'));
// first hunk has 3 lines (indices 0-2), separator should be at index 3
expect(separatorIndex).toBe(3);
});
it('should render the ellipsis character in separator', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const separator = container.querySelector('.separator-content');
expect(separator.textContent).toBe('\u22EE');
});
it('should apply warning highlights within hunks', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={sampleHunks} variant="warning" />);
const highlighted = container.querySelectorAll('.highlighted-warning');
expect(highlighted).toHaveLength(2);
});
it('should render nothing when hunks is empty array', () => {
const { container } = renderWithTheme(<CodeSnippet hunks={[]} />);
expect(container.firstChild).toBeNull();
});
});
});

View File

@@ -51,6 +51,11 @@ const AuthMode = ({ collection }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -0,0 +1,26 @@
import React from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
const CollectionOAuth1 = ({ collection }) => {
const dispatch = useDispatch();
const request = collection.draft?.root
? get(collection, 'draft.root.request', {})
: get(collection, 'root.request', {});
const save = () => dispatch(saveCollectionSettings(collection.uid));
return (
<OAuth1
collection={collection}
request={request}
save={save}
updateAuth={updateCollectionAuth}
/>
);
};
export default CollectionOAuth1;

View File

@@ -12,6 +12,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2';
import NTLMAuth from './NTLMAuth';
import OAuth1 from './Oauth1';
import Button from 'ui/Button';
const Auth = ({ collection }) => {
@@ -37,6 +38,9 @@ const Auth = ({ collection }) => {
case 'ntlm': {
return <NTLMAuth collection={collection} />;
}
case 'oauth1': {
return <OAuth1 collection={collection} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} />;
}

View File

@@ -1,9 +1,25 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
height: 100%;
overflow-y: auto;
.editing-mode {
cursor: pointer;
position: sticky;
top: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding: 6px 0;
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
}
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
`;

View File

@@ -1,9 +1,10 @@
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { setCollectionHeaders } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
@@ -18,11 +19,21 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = collection.draft?.root
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionHeadersWidths = focusedTab?.tableColumnWidths?.['collection-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
@@ -114,11 +125,14 @@ const Headers = ({ collection }) => {
Add request headers that will be sent with every request in this collection.
</div>
<EditableTable
tableId="collection-headers"
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={collectionHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -18,27 +20,24 @@ const Script = ({ collection }) => {
const requestScript = collection.draft?.root ? get(collection, 'draft.root.request.script.req', '') : get(collection, 'root.request.script.req', '');
const responseScript = collection.draft?.root ? get(collection, 'draft.root.request.script.res', '') : get(collection, 'root.request.script.res', '');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === collection.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
const getDefaultTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevCollectionUidRef = useRef(collection.uid);
const activeTab = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab: tab }));
};
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different collection
useEffect(() => {
if (prevCollectionUidRef.current !== collection.uid) {
prevCollectionUidRef.current = collection.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [collection.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -1,6 +1,10 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
div.tabs {
div.tab {
padding: 6px 0px;
@@ -24,7 +28,8 @@ const StyledWrapper = styled.div`
}
&.active {
font-weight: ${(props) => props.theme.tabs.active.fontWeight} !important;
font-weight: ${(props) =>
props.theme.tabs.active.fontWeight} !important;
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
@@ -45,7 +50,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
input[type='radio'] {
input[type="radio"] {
cursor: pointer;
accent-color: ${(props) => props.theme.primary.solid};
}

View File

@@ -1,7 +1,8 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
@@ -13,6 +14,16 @@ import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index
const VarsTable = ({ collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const collectionVarsWidths = focusedTab?.tableColumnWidths?.['collection-vars'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveCollectionSettings(collection.uid));
@@ -68,11 +79,14 @@ const VarsTable = ({ collection, vars, varType }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="collection-vars"
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={collectionVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
/>
</StyledWrapper>
);

View File

@@ -106,42 +106,42 @@ const CollectionSettings = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-hidden">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
<div className={getTabClassname('overview')} role="tab" data-testid="collection-settings-tab-overview" onClick={() => setTab('overview')}>
Overview
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
<div className={getTabClassname('headers')} role="tab" data-testid="collection-settings-tab-headers" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
<div className={getTabClassname('vars')} role="tab" data-testid="collection-settings-tab-vars" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
<div className={getTabClassname('auth')} role="tab" data-testid="collection-settings-tab-auth" onClick={() => setTab('auth')}>
Auth
{authMode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
<div className={getTabClassname('script')} role="tab" data-testid="collection-settings-tab-script" onClick={() => setTab('script')}>
Script
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
<div className={getTabClassname('tests')} role="tab" data-testid="collection-settings-tab-tests" onClick={() => setTab('tests')}>
Tests
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
<div className={getTabClassname('presets')} role="tab" data-testid="collection-settings-tab-presets" onClick={() => setTab('presets')}>
Presets
{hasPresets && <StatusDot />}
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
<div className={getTabClassname('proxy')} role="tab" data-testid="collection-settings-tab-proxy" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && proxyEnabled && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
<div className={getTabClassname('clientCert')} role="tab" data-testid="collection-settings-tab-clientCert" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('protobuf')} role="tab" onClick={() => setTab('protobuf')}>
<div className={getTabClassname('protobuf')} role="tab" data-testid="collection-settings-tab-protobuf" onClick={() => setTab('protobuf')}>
Protobuf
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>

View File

@@ -113,7 +113,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
return (
<StyledSessionList>
{sessions.map((session) => {
{sessions.map((session, idx) => {
const { name } = getSessionDisplayInfo(session);
return (
<ToolHint
@@ -125,6 +125,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
>
<div
className={`session-list-item ${activeSessionId === session.sessionId ? 'active' : ''}`}
data-testid={`session-list-${idx}`}
onClick={() => onSelectSession(session.sessionId)}
>
<div className="session-name">
@@ -133,6 +134,7 @@ const SessionList = ({ sessions, activeSessionId, onSelectSession, onCloseSessio
</div>
<div
className="session-close-btn"
data-testid={`session-close-${idx}`}
onClick={(e) => {
e.stopPropagation();
onCloseSession(session.sessionId);

View File

@@ -1,8 +1,20 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {
cursor: pointer;
position: sticky;
z-index: 10;
top: 0;
background: ${(props) => props.theme.bg};
padding-bottom: 0.5em;
}
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
`;

View File

@@ -1,6 +1,8 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -12,12 +14,15 @@ import StyledWrapper from './StyledWrapper';
const Documentation = ({ item, collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const [isEditing, setIsEditing] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const isEditing = focusedTab?.docsEditing || false;
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
const preferences = useSelector((state) => state.app.preferences);
const toggleViewMode = () => {
setIsEditing((prev) => !prev);
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
};
const onEdit = (value) => {

View File

@@ -63,7 +63,7 @@ const StyledWrapper = styled.div`
height: 100%;
cursor: col-resize;
background: transparent;
z-index: 100;
z-index: 10;
&:hover,
&.resizing {

View File

@@ -7,6 +7,7 @@ import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const EditableTable = ({
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
columns,
rows,
onChange,
@@ -20,20 +21,20 @@ const EditableTable = ({
reorderable = false,
onReorder,
showAddRow = true,
testId = 'editable-table'
testId = 'editable-table',
columnWidths,
onColumnWidthsChange
}) => {
const tableRef = useRef(null);
const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const [columnWidths, setColumnWidths] = useState(() => {
const initialWidths = {};
columns.forEach((col) => {
initialWidths[col.key] = col.width || 'auto';
});
return initialWidths;
});
const widths = columnWidths || {};
const handleColumnWidthsChange = useCallback((newWidths) => {
onColumnWidthsChange?.(newWidths);
}, [onColumnWidthsChange]);
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -59,11 +60,13 @@ const EditableTable = ({
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
setColumnWidths((prev) => ({
...prev,
const newWidths = {
...widths,
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
}));
};
handleColumnWidthsChange(newWidths);
};
const handleMouseUp = () => {
@@ -88,7 +91,7 @@ const EditableTable = ({
});
if (Object.keys(newWidths).length > 0) {
setColumnWidths((prev) => ({ ...prev, ...newWidths }));
handleColumnWidthsChange({ ...widths, ...newWidths });
}
}
setResizing(null);
@@ -98,7 +101,7 @@ const EditableTable = ({
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox]);
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
// Track table height for resize handles
useEffect(() => {
@@ -118,8 +121,8 @@ const EditableTable = ({
}, [rows.length]);
const getColumnWidth = useCallback((column) => {
return columnWidths[column.key] || column.width || 'auto';
}, [columnWidths]);
return widths[column.key] || column.width || 'auto';
}, [widths]);
const createEmptyRow = useCallback(() => {
const newUid = uuid();

View File

@@ -99,24 +99,6 @@ const Wrapper = styled.div`
.name-cell-wrapper {
position: relative;
width: 100%;
.name-highlight-overlay {
position: absolute;
inset: 0;
pointer-events: none;
white-space: pre;
overflow: hidden;
font-size: inherit;
line-height: inherit;
color: ${(props) => props.theme.text};
}
}
.search-highlight {
background: ${(props) => props.theme.colors.accent}55;
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
.no-results {

View File

@@ -3,7 +3,8 @@ import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { useSelector, useDispatch } from 'react-redux';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor/index';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
@@ -31,15 +32,6 @@ const TableRow = React.memo(
}
);
const highlightText = (text, query) => {
if (!query?.trim() || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
);
};
const EnvironmentVariablesTable = ({
environment,
collection,
@@ -51,16 +43,43 @@ const EnvironmentVariablesTable = ({
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme, theme } = useTheme();
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const activeWorkspace = useSelector((state) => {
const uid = state.workspaces?.activeWorkspaceUid;
return state.workspaces?.workspaces?.find((w) => w.uid === uid);
});
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
const [tableHeight, setTableHeight] = useState(MIN_H);
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
// Use environment UID as part of tableId so each environment has its own column widths
const tableId = `env-vars-table-${environment.uid}`;
// Get column widths from Redux - derived value (not state)
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const storedColumnWidths = focusedTab?.tableColumnWidths?.[tableId];
// Local state initialized from Redux (computed once on mount/environment change via key)
const [columnWidths, setColumnWidths] = useState(() => {
return storedColumnWidths || { name: '30%', value: 'auto' };
});
const [resizing, setResizing] = useState(null);
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
const handleColumnWidthsChange = (id, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId: id, widths }));
};
// Store column widths in ref for access in event handlers
const columnWidthsRef = useRef(columnWidths);
columnWidthsRef.current = columnWidths;
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -83,26 +102,36 @@ const EnvironmentVariablesTable = ({
const maxShrink = startWidth - MIN_COLUMN_WIDTH;
const clampedDiff = Math.max(-maxShrink, Math.min(maxGrow, diff));
setColumnWidths({
const newWidths = {
[columnKey]: `${startWidth + clampedDiff}px`,
[nextColumnKey]: `${nextColumnStartWidth - clampedDiff}px`
});
};
setColumnWidths(newWidths);
};
const handleMouseUp = () => {
setResizing(null);
// Save to Redux after resize ends using ref for latest values
handleColumnWidthsChange(tableId, columnWidthsRef.current);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}, []);
}, [handleColumnWidthsChange]);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
const handleRowFocus = useCallback((uid) => {
setPinnedData((prev) => ({
query: searchQuery,
uids: prev.query === searchQuery ? new Set([...prev.uids, uid]) : new Set([uid])
}));
}, [searchQuery]);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
@@ -113,6 +142,12 @@ const EnvironmentVariablesTable = ({
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// When collection is null (global/workspace environments), populate process env
// variables from the active workspace so that {{process.env.X}} can resolve
if (!collection && activeWorkspace?.processEnvVariables) {
_collection.workspaceProcessEnvVariables = activeWorkspace.processEnvVariables;
}
const initialValues = useMemo(() => {
const vars = environment.variables || [];
return [
@@ -205,6 +240,10 @@ const EnvironmentVariablesTable = ({
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
}, [environment.variables]);
useEffect(() => {
setPinnedData({ query: '', uids: new Set() });
}, [savedValuesJson]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
@@ -358,6 +397,7 @@ const EnvironmentVariablesTable = ({
onSave(cloneDeep(variablesToSave))
.then(() => {
toast.success('Changes saved successfully');
onDraftClear();
const newValues = [
...variablesToSave,
{
@@ -376,7 +416,7 @@ const EnvironmentVariablesTable = ({
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, setIsModified]);
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
const handleReset = useCallback(() => {
const originalVars = environment.variables || [];
@@ -418,12 +458,20 @@ const EnvironmentVariablesTable = ({
const query = searchQuery.toLowerCase().trim();
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
return allVariables.filter(({ variable }) => {
if (effectivePins.has(variable.uid)) return true;
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
const valueText
= typeof variable.value === 'string'
? variable.value
: typeof variable.value === 'number' || typeof variable.value === 'boolean'
? String(variable.value)
: '';
const valueMatch = valueText.toLowerCase().includes(query);
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery]);
}, [formik.values, searchQuery, pinnedData]);
const isSearchActive = !!searchQuery?.trim();
@@ -460,11 +508,6 @@ const EnvironmentVariablesTable = ({
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
const activeQuery = searchQuery?.trim().toLowerCase();
const valueMatchesOnly = activeQuery
&& !(variable.name?.toLowerCase().includes(activeQuery))
&& typeof variable.value === 'string'
&& variable.value.toLowerCase().includes(activeQuery);
return (
<>
@@ -475,8 +518,7 @@ const EnvironmentVariablesTable = ({
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
onChange={formik.handleChange}
/>
)}
</td>
@@ -494,29 +536,25 @@ const EnvironmentVariablesTable = ({
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
readOnly={isSearchActive}
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
onBlur={() => {
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
handleNameBlur(actualIndex);
}}
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
<div className="name-highlight-overlay">
{highlightText(variable.name || '', searchQuery)}
</div>
)}
</div>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
style={{ width: columnWidths.value }}
>
<div className="overflow-hidden grow w-full relative">
<div
className="overflow-hidden grow w-full relative"
onFocus={() => handleRowFocus(variable.uid)}
>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
@@ -524,7 +562,7 @@ const EnvironmentVariablesTable = ({
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={isSearchActive || typeof variable.value !== 'string'}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
@@ -555,14 +593,13 @@ const EnvironmentVariablesTable = ({
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}

View File

@@ -8,11 +8,12 @@ const CollapsibleSection = ({
onToggle,
badge,
actions,
children
children,
testId
}) => {
return (
<StyledWrapper className={expanded ? 'expanded' : 'collapsed'}>
<div className="section-header" onClick={onToggle}>
<div className="section-header" onClick={onToggle} data-testid={testId}>
<div className="section-title-wrapper">
<IconChevronRight
size={14}

View File

@@ -44,6 +44,7 @@ const DotEnvFileDetails = ({
className={`toggle-btn ${viewMode === 'raw' ? 'active' : ''}`}
onClick={() => onViewModeChange?.('raw')}
aria-pressed={viewMode === 'raw'}
data-testid="dotenv-view-raw"
>
Raw
</button>

View File

@@ -13,7 +13,7 @@ const DotEnvRawView = ({
}) => {
return (
<>
<div className="raw-editor-container">
<div className="raw-editor-container" data-testid="dotenv-raw-editor">
<CodeEditor
collection={collection}
item={item}

View File

@@ -4,6 +4,7 @@ import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import useDeferredLoading from 'hooks/useDeferredLoading';
import StyledWrapper from './StyledWrapper';
import DotEnvTableView from './DotEnvTableView';
@@ -31,6 +32,7 @@ const DotEnvFileEditor = ({
const [rawValue, setRawValue] = useState(initialRawValue);
const [prevViewMode, setPrevViewMode] = useState(viewMode);
const [isSaving, setIsSaving] = useState(false);
const showSaving = useDeferredLoading(isSaving, 200);
const formikRef = useRef(null);
@@ -311,7 +313,7 @@ const DotEnvFileEditor = ({
onChange={handleRawChange}
onSave={handleSaveRaw}
onReset={handleReset}
isSaving={isSaving}
isSaving={showSaving}
/>
</StyledWrapper>
);
@@ -335,7 +337,7 @@ const DotEnvFileEditor = ({
onRemoveVar={handleRemoveVar}
onSave={handleSave}
onReset={handleReset}
isSaving={isSaving}
isSaving={showSaving}
/>
</StyledWrapper>
);

View File

@@ -1,17 +1,8 @@
import { uuid } from 'utils/common';
import { utils } from '@usebruno/common';
export const variablesToRaw = (variables) => {
return variables
.filter((v) => v.name && v.name.trim() !== '')
.map((v) => {
const value = v.value || '';
if (value.includes('\n') || value.includes('"') || value.includes('\'')) {
const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
return `${v.name}="${escapedValue}"`;
}
return `${v.name}=${value}`;
})
.join('\n');
return utils.jsonToDotenv(variables);
};
export const rawToVariables = (rawContent) => {
@@ -37,9 +28,16 @@ export const rawToVariables = (rawContent) => {
const name = trimmedLine.substring(0, equalIndex).trim();
let value = trimmedLine.substring(equalIndex + 1);
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''))) {
if (value.startsWith('\'') && value.endsWith('\'')) {
// Single-quoted values are fully literal in dotenv — no unescaping
value = value.slice(1, -1);
value = value.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
} else if (value.startsWith('`') && value.endsWith('`')) {
// Backtick-quoted values are fully literal in dotenv — no unescaping
value = value.slice(1, -1);
} else if (value.startsWith('"') && value.endsWith('"')) {
// Double-quoted values: unescape \", \n, and \r (the escapes we produce)
value = value.slice(1, -1);
value = value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\r/g, '\r');
}
if (name) {

View File

@@ -46,7 +46,7 @@ const EnvironmentListContent = ({
</div>
</ToolHint>
<div className="dropdown-item configure-button">
<button onClick={onSettingsClick} id="configure-env">
<button onClick={onSettingsClick} id="configure-env" data-testid="configure-env">
<IconSettings size={16} strokeWidth={1.5} />
<span>Configure</span>
</button>

View File

@@ -103,6 +103,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
return (
<EnvironmentVariablesTable
key={environment?.uid}
environment={environment}
collection={collection}
onSave={handleSave}

View File

@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 8px 20px;
padding: 9px 20px 8px 20px;
flex-shrink: 0;
.title {

View File

@@ -72,7 +72,7 @@ const StyledWrapper = styled.div`
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 5px;
border-radius: 6px;
color: ${(props) => props.theme.text};
transition: border-color 0.15s ease;
@@ -110,7 +110,15 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 8px;
padding: 8px;
}
.section-header {
margin-inline: 4px !important;
padding-left: 6px !important;
border-radius: 6px ;
padding-right: 3px !important;
padding-block: 4px !important;
}
.environments-list {
@@ -153,7 +161,7 @@ const StyledWrapper = styled.div`
font-size: 13px;
color: ${(props) => props.theme.text};
cursor: pointer;
border-radius: 5px;
border-radius: 6px;
transition: background 0.15s ease;
.environment-name {

View File

@@ -49,7 +49,6 @@ const EnvironmentList = ({
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
const envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
@@ -84,6 +83,8 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const environmentsDraftUid = collection?.environmentsDraft?.environmentUid;
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
@@ -92,10 +93,10 @@ const EnvironmentList = ({
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
} else if (environmentsDraftUid?.startsWith('dotenv:')) {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, [dispatch, collection.uid, selectedDotEnvFile]);
}, [dispatch, collection.uid, selectedDotEnvFile, environmentsDraftUid]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
@@ -558,51 +559,65 @@ const EnvironmentList = ({
<>
<button
type="button"
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
className="btn-action"
onClick={() => {
const next = !isEnvListSearchExpanded;
setIsEnvListSearchExpanded(next);
if (!next) setSearchText('');
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleCreateEnvClick();
}}
title="Search environments"
title="Create environment"
>
<IconSearch size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleImportClick();
}}
title="Import environment"
>
<IconDownload size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleExportClick();
}}
title="Export environment"
>
<IconUpload size={14} strokeWidth={1.5} />
</button>
</>
)}
>
{isEnvListSearchExpanded && (
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
)}
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button
className="env-list-search-clear"
title="Clear search"
onClick={() => setSearchText('')}
onMouseDown={(e) => e.preventDefault()}
>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div
@@ -721,6 +736,7 @@ const EnvironmentList = ({
<CollapsibleSection
title=".env Files"
testId="dotenv-files-section"
expanded={dotEnvExpanded}
onToggle={() => setDotEnvExpanded(!dotEnvExpanded)}
badge={dotEnvFiles.length}
@@ -729,6 +745,7 @@ const EnvironmentList = ({
className="btn-action"
onClick={handleCreateDotEnvInlineClick}
title="Create .env file"
data-testid="create-dotenv-file"
>
<IconPlus size={14} strokeWidth={1.5} />
</button>
@@ -753,6 +770,7 @@ const EnvironmentList = ({
ref={dotEnvInputRef}
type="text"
className="environment-name-input"
data-testid="dotenv-name-input"
value={newDotEnvName}
onChange={handleDotEnvNameChange}
onKeyDown={handleDotEnvNameKeyDown}

View File

@@ -14,6 +14,7 @@ import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
@@ -143,6 +144,17 @@ const Auth = ({ collection, folder }) => {
/>
);
}
case 'oauth1': {
return (
<OAuth1
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'wsse': {
return (
<WsseAuth

View File

@@ -47,6 +47,11 @@ const AuthMode = ({ collection, folder }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -1,9 +1,18 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {
cursor: pointer;
color: ${(props) => props.theme.colors.text.yellow};
position: sticky;
top: 0;
z-index: 10;
background: ${(props) => props.theme.bg};
padding-bottom: 0.5em;
}
`;

View File

@@ -1,9 +1,10 @@
import React, { useState, useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { setFolderHeaders } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
@@ -18,11 +19,21 @@ const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = folder.draft
? get(folder, 'draft.request.headers', [])
: get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const folderHeadersWidths = focusedTab?.tableColumnWidths?.['folder-headers'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
@@ -119,11 +130,14 @@ const Headers = ({ collection, folder }) => {
Request headers that will be sent with every request inside this folder.
</div>
<EditableTable
tableId="folder-headers"
columns={columns}
rows={headers}
onChange={handleHeadersChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={folderHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -18,27 +20,25 @@ const Script = ({ collection, folder }) => {
const requestScript = folder.draft ? get(folder, 'draft.request.script.req', '') : get(folder, 'root.request.script.req', '');
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
const getDefaultTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevFolderUidRef = useRef(folder.uid);
const activeTab = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
};
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different folder
useEffect(() => {
if (prevFolderUidRef.current !== folder.uid) {
prevFolderUidRef.current = folder.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [folder.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
const timer = setTimeout(() => {

View File

@@ -2,6 +2,12 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
max-width: 800px;
position: relative;
.markdown-body {
height: auto !important;
overflow-y: visible !important;
}
div.tabs {
div.tab {

View File

@@ -1,7 +1,8 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import EditableTable from 'components/EditableTable';
@@ -13,6 +14,16 @@ import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ folder, collection, vars, varType }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const folderVarsWidths = focusedTab?.tableColumnWidths?.['folder-vars'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
@@ -74,11 +85,14 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="folder-vars"
columns={columns}
rows={vars}
onChange={handleVarsChange}
defaultRow={defaultRow}
getRowError={getRowError}
columnWidths={folderVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
/>
</StyledWrapper>
);

View File

@@ -77,27 +77,27 @@ const FolderSettings = ({ collection, folder }) => {
<StyledWrapper className="flex flex-col h-full overflow-auto">
<div className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
<div className={getTabClassname('headers')} role="tab" data-testid="folder-settings-tab-headers" onClick={() => setTab('headers')}>
Headers
{activeHeadersCount > 0 && <sup className="ml-1 font-medium">{activeHeadersCount}</sup>}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
<div className={getTabClassname('script')} role="tab" data-testid="folder-settings-tab-script" onClick={() => setTab('script')}>
Script
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
<div className={getTabClassname('test')} role="tab" data-testid="folder-settings-tab-test" onClick={() => setTab('test')}>
Test
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
<div className={getTabClassname('vars')} role="tab" data-testid="folder-settings-tab-vars" onClick={() => setTab('vars')}>
Vars
{activeVarsCount > 0 && <sup className="ml-1 font-medium">{activeVarsCount}</sup>}
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
Auth
{hasAuth && <StatusDot />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
Docs
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconSearch,
@@ -13,6 +13,7 @@ import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { toggleCollectionItem, toggleCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { normalizePath } from 'utils/common/path';
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';
@@ -26,9 +27,21 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
const debounceTimeoutRef = useRef(null);
const dispatch = useDispatch();
const collections = useSelector((state) => state.collections.collections);
const allCollections = useSelector((state) => state.collections.collections);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const tabs = useSelector((state) => state.tabs.tabs);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const collections = useMemo(() => {
if (!activeWorkspace) return allCollections;
const workspacePaths = new Set(
activeWorkspace.collections?.map((wc) => normalizePath(wc.path)) || []
);
return allCollections.filter((c) => workspacePaths.has(normalizePath(c.pathname)));
}, [activeWorkspace, allCollections, workspaces]);
const createCollectionResults = () => {
const collectionResults = collections.map((collection) => ({
type: SEARCH_TYPES.COLLECTION,
@@ -389,6 +402,7 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
aria-activedescendant={results.length > 0 ? `search-result-${selectedIndex}` : undefined}
role="combobox"
aria-autocomplete="list"
data-testid="global-search-input"
/>
{query && (
<button

View File

@@ -25,7 +25,7 @@ const RenameWorkspace = ({ onClose, workspace }) => {
.test('unique-name', 'A workspace with this name already exists', function (value) {
if (!value) return true;
return !workspaces.some((w) =>
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
w.uid !== workspace.uid && w.name && w.name.toLowerCase() === value.toLowerCase()
);
})
}),

View File

@@ -27,7 +27,8 @@ const ManageWorkspace = () => {
const [deleteWorkspaceModal, setDeleteWorkspaceModal] = useState({ open: false, workspace: null });
const sortedWorkspaces = useMemo(() => {
return sortWorkspaces(workspaces, preferences);
const persistedWorkspaces = workspaces.filter((w) => !w.isCreating);
return sortWorkspaces(persistedWorkspaces, preferences);
}, [workspaces, preferences]);
const handleBack = () => {
@@ -69,7 +70,6 @@ const ManageWorkspace = () => {
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}

View File

@@ -3,6 +3,8 @@ import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
import StyledWrapper from './StyledWrapper';
import React from 'react';
import { isValidUrl } from 'utils/url/index';
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = {
@@ -33,14 +35,14 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
};
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
const htmlFromMarkdown = md.render(content || '');
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
return (
<StyledWrapper>
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: htmlFromMarkdown }}
dangerouslySetInnerHTML={{ __html: cleanHTML }}
onClick={handleOnClick}
onDoubleClick={handleOnDoubleClick}
/>

View File

@@ -6,7 +6,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -25,9 +24,6 @@ class MultiLineEditor extends Component {
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -49,26 +45,6 @@ class MultiLineEditor extends Component {
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
// 'Ctrl-Enter': () => {
// if (this.props.onRun) {
// this.props.onRun();
// }
// },
// 'Cmd-Enter': () => {
// if (this.props.onRun) {
// this.props.onRun();
// }
// },
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Cmd-F': () => {},
'Ctrl-F': () => {},
// Tabbing disabled to make tabindex work
@@ -94,11 +70,15 @@ class MultiLineEditor extends Component {
setupLinkAware(this.editor);
// Setup keyboard shortcuts
this._shortcutsCleanup = setupShortcuts(this.editor, this);
// Add mousetrap calss so Mousetrap captures shortcuts even when Codemirror is focused
const cmInput = this.editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.editor.on('blur', this._onBlur);
this.addOverlay(variables);
// Initialize masking if this is a secret field
@@ -106,6 +86,12 @@ class MultiLineEditor extends Component {
this._enableMaskedEditor(this.props.isSecret);
}
_onBlur = () => {
if (this.editor) {
this.editor.setCursor(this.editor.getCursor());
}
};
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.cachedValue = this.editor.getValue();
@@ -161,16 +147,13 @@ class MultiLineEditor extends Component {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = String(this.props.value ?? '');
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
// Re-apply masking after setValue() since it destroys all CodeMirror marks
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
this.maskedEditor.update();
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
@@ -186,12 +169,6 @@ class MultiLineEditor extends Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
@@ -202,7 +179,11 @@ class MultiLineEditor extends Component {
this.maskedEditor.destroy();
this.maskedEditor = null;
}
this.editor.getWrapperElement().remove();
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor.off('blur', this._onBlur);
this.editor.getWrapperElement().remove();
}
}
addOverlay = (variables) => {

View File

@@ -1,8 +1,22 @@
import React, { useState, useEffect, useCallback } from 'react';
import { IconLoader2, IconCloud } from '@tabler/icons';
import fastJsonFormat from 'fast-json-format';
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
/**
* Pretty-print JSON content for readable display. YAML content is returned as-is.
*/
const prettyPrintSpec = (content) => {
if (!content) return content;
if (content.trimStart()[0] !== '{') return content;
try {
return fastJsonFormat(content);
} catch {
return content;
}
};
const OpenAPISpecTab = ({ collection }) => {
const [specContent, setSpecContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -19,8 +33,7 @@ const OpenAPISpecTab = ({ collection }) => {
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:read-openapi-spec', {
collectionPath: collection.pathname,
sourceUrl
collectionPath: collection.pathname
});
if (result.error) {
// Local file not found — fall back to fetching from remote URL
@@ -37,14 +50,14 @@ const OpenAPISpecTab = ({ collection }) => {
}
});
if (fetchResult.content) {
setSpecContent(fetchResult.content);
setSpecContent(prettyPrintSpec(fetchResult.content));
setIsRemote(true);
return;
}
}
setError(result.error);
} else {
setSpecContent(result.content);
setSpecContent(prettyPrintSpec(result.content));
}
} catch (err) {
setError(err.message || 'Failed to read spec file');

View File

@@ -5,15 +5,15 @@ import {
IconTrash,
IconArrowBackUp,
IconExternalLink,
IconClock,
IconInfoCircle
IconAlertTriangle,
IconInfoCircle,
IconLoader2
} from '@tabler/icons';
import moment from 'moment';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import Modal from 'components/Modal';
import EndpointChangeSection from '../EndpointChangeSection';
import EndpointItem from '../EndpointChangeSection/EndpointItem';
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
import useEndpointActions from '../hooks/useEndpointActions';
@@ -24,7 +24,9 @@ const CollectionStatusSection = ({
specDrift,
storedSpec,
lastSyncDate,
onOpenEndpoint
onOpenEndpoint,
isLoading,
onTabSelect
}) => {
const {
pendingAction, setPendingAction,
@@ -39,7 +41,8 @@ const CollectionStatusSection = ({
} = useEndpointActions(collection, collectionDrift, reloadDrift);
const spec = storedSpec || specDrift?.newSpec;
const hasDrift = !!collectionDrift && (collectionDrift.modified?.length > 0
const hasStoredSpec = collectionDrift && !collectionDrift.noStoredSpec;
const hasDrift = hasStoredSpec && (collectionDrift.modified?.length > 0
|| collectionDrift.missing?.length > 0
|| collectionDrift.localOnly?.length > 0);
@@ -85,12 +88,6 @@ const CollectionStatusSection = ({
: <div className={`status-dot ${bannerState.variant}`} />}
<span className="banner-title">
{bannerState.message}
{bannerState.version && (
<> &middot; <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
)}
{bannerState.lastSyncDate && (
<span className="checked-text"> &middot; Synced {moment(bannerState.lastSyncDate).fromNow()}</span>
)}
</span>
{bannerState.badges && (
<span className="banner-details">
@@ -113,7 +110,7 @@ const CollectionStatusSection = ({
{hasDrift && (
<div className="sync-info-notice mt-4">
<IconInfoCircle size={14} className="sync-info-icon" />
<span><span className="whats-updated-title">What's tracked:</span> Changes to URL, parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
<span><span className="whats-updated-title">What's tracked:</span> Changes to parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
</div>
)}
@@ -211,11 +208,27 @@ const CollectionStatusSection = ({
)}
/>
</div>
) : isLoading ? (
<div className="sync-review-empty-state mt-5">
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
<h4>Checking for updates</h4>
<p>Comparing your collection with the last synced spec...</p>
</div>
) : !hasStoredSpec ? (
<div className="sync-review-empty-state mt-5">
<IconAlertTriangle size={40} className="empty-state-icon" />
<h4>{lastSyncDate ? 'Cannot track collection changes' : 'Waiting for initial sync'}</h4>
<p>{lastSyncDate
? 'The last synced spec is missing. Go to the \'Spec Updates\' tab to restore it, or sync the collection if updates are available to track future changes.'
: 'Once you sync your collection with the spec, local changes will appear here.'}
</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => onTabSelect('spec-updates')}>Go to Spec Updates</Button>
</div>
) : (
<div className="sync-review-empty-state mt-5">
<IconCheck size={40} className="empty-state-icon" />
<h4>No changes in collection</h4>
<p>The collection matches the last synced spec. Nothing to review.</p>
<p>The collection endpoints match the last synced spec. Nothing to review.</p>
</div>
)}
{/* Action confirmation modal */}

View File

@@ -1,7 +1,7 @@
import { useState, useRef } from 'react';
import { IconCheck } from '@tabler/icons';
import Button from 'ui/Button';
import { isValidUrl } from 'utils/url/index';
import { isHttpUrl } from 'utils/url/index';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
@@ -77,7 +77,11 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
try {
const data = await parseFileAsJsonOrYaml(file);
if (!isOpenApiSpec(data)) {
setError('The selected file is not a valid OpenAPI specification');
setError('The selected file is not a valid OpenAPI 3.x specification');
return;
}
if (data.swagger && String(data.swagger).startsWith('2')) {
setError('Swagger 2.0 is not supported. Please convert your spec to OpenAPI 3.x.');
return;
}
const filePath = window.ipcRenderer.getFilePath(file);
@@ -100,7 +104,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
<Button
type="submit"
size="sm"
disabled={mode === 'url' ? !isValidUrl(sourceUrl.trim()) : !sourceUrl.trim()}
disabled={mode === 'url' ? !isHttpUrl(sourceUrl.trim()) : !sourceUrl.trim()}
loading={isLoading}
>
Connect
@@ -124,6 +128,17 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
</div>
))}
</div>
<p className="beta-feedback-inline">
OpenAPI Sync is in Beta we'd love to hear your feedback and suggestions.{' '}
<button
type="button"
className="beta-feedback-link"
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
>
Share feedback
</button>
</p>
</div>
);
};

View File

@@ -2,17 +2,18 @@ import { useState, useRef } from 'react';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import Modal from 'components/Modal';
import { isValidUrl } from 'utils/url/index';
import { isHttpUrl } from 'utils/url/index';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const isUrl = isValidUrl(sourceUrl);
const normalizedSourceUrl = (sourceUrl || '').trim();
const isUrl = isHttpUrl(normalizedSourceUrl);
const initialMode = isUrl ? 'url' : 'file';
const [mode, setMode] = useState(initialMode);
const [url, setUrl] = useState(isUrl ? (sourceUrl || '') : '');
const [filePath, setFilePath] = useState(isUrl ? '' : sourceUrl);
const [url, setUrl] = useState(isUrl ? normalizedSourceUrl : '');
const [filePath, setFilePath] = useState(isUrl ? '' : normalizedSourceUrl);
const [autoCheck, setAutoCheck] = useState(openApiSyncConfig?.autoCheck !== false);
const [checkInterval, setCheckInterval] = useState(openApiSyncConfig?.autoCheckInterval || 5);
const [isSaving, setIsSaving] = useState(false);
@@ -21,7 +22,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
const intervals = [5, 15, 30, 60];
const effectiveSource = mode === 'file' ? filePath : url.trim();
const canSave = mode === 'file' ? !!effectiveSource : isValidUrl(effectiveSource.trim());
const canSave = mode === 'file' ? !!effectiveSource : isHttpUrl(effectiveSource.trim());
const handleSave = async () => {
setIsSaving(true);
@@ -84,7 +85,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
try {
const data = await parseFileAsJsonOrYaml(file);
if (!isOpenApiSpec(data)) {
toast.error('The selected file is not a valid OpenAPI specification');
toast.error('The selected file is not a valid OpenAPI 3.x specification');
return;
}
const path = window.ipcRenderer.getFilePath(file);

View File

@@ -15,7 +15,7 @@ const DisconnectSyncModal = ({ onConfirm, onClose }) => {
<>This will only disconnect the sync configuration. Your collection will remain intact.</>
</p>
<div className="disconnect-actions">
<Button variant="ghost" onClick={onClose}>
<Button variant="ghost" color="secondary" onClick={onClose}>
Cancel
</Button>
<Button color="danger" onClick={onConfirm}>

View File

@@ -1,3 +1,6 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
IconCopy,
IconDotsVertical,
@@ -9,7 +12,6 @@ import {
} from '@tabler/icons';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import ActionIcon from 'ui/ActionIcon/index';
import MenuDropdown from 'ui/MenuDropdown';
import Help from 'components/Help';
@@ -23,8 +25,20 @@ const OpenAPISyncHeader = ({
const sourceIsLocal = !isHttpUrl(sourceUrl);
const canCheck = !!sourceUrl?.trim();
const title = spec?.info?.title || 'Unknown API';
const version = spec?.info?.version || '-';
// Resolve relative file paths to absolute for display
const [displayPath, setDisplayPath] = useState(sourceUrl);
useEffect(() => {
if (sourceIsLocal && sourceUrl) {
window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname)
.then((resolved) => setDisplayPath(resolved))
.catch(() => setDisplayPath(sourceUrl));
} else {
setDisplayPath(sourceUrl);
}
}, [sourceUrl, sourceIsLocal, collection.pathname]);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
const copyUrl = async () => {
if (!sourceUrl) return;
@@ -111,7 +125,7 @@ const OpenAPISyncHeader = ({
type="button"
onClick={revealInFolder}
>
{sourceUrl}
{displayPath}
</button>
) : (
<a

View File

@@ -1,24 +1,13 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { countEndpoints } from '../utils';
import moment from 'moment';
import { IconCheck } from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import Help from 'components/Help';
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
const countEndpoints = (spec) => {
if (!spec?.paths) return null;
let count = 0;
for (const path of Object.values(spec.paths)) {
for (const key of Object.keys(path)) {
if (HTTP_METHODS.includes(key.toLowerCase())) count++;
}
}
return count;
};
const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
const SUMMARY_CARDS = [
@@ -32,7 +21,7 @@ const SUMMARY_CARDS = [
key: 'inSync',
label: 'In Sync with Spec',
color: 'green',
tooltip: 'Endpoints that currently match the latest spec'
tooltip: 'Endpoints that currently match the latest spec from the source'
},
{
key: 'changed',
@@ -50,27 +39,26 @@ const SUMMARY_CARDS = [
}
];
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, fileNotFound, onOpenSettings }) => {
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, onOpenSettings }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const activeError = error || reduxError;
const version = storedSpec?.info?.version;
const endpointCount = countEndpoints(storedSpec);
const version = specMeta?.version;
const endpointCount = specMeta?.endpointCount ?? null;
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
const groupBy = openApiSyncConfig?.groupBy || 'tags';
const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false;
const autoCheckInterval = openApiSyncConfig?.autoCheckInterval || 5;
// Endpoint Summary counts
// Total/In Sync: always compare against remote spec
// Total: from collection items in Redux; In Sync: from remote spec comparison
// Changed/Conflicts: compare against stored spec in AppData (0 on initial sync)
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
const totalInCollection = remoteDrift
? (remoteDrift.inSync?.length || 0) + (remoteDrift.modified?.length || 0) + (remoteDrift.localOnly?.length || 0)
: null;
const totalInCollection = getTotalRequestCountInCollection(collection);
const inSyncCount = remoteDrift
? (remoteDrift.inSync?.length || 0)
@@ -111,6 +99,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
const hasSpecUpdates = specUpdatesPending > 0;
const bannerState = useMemo(() => {
const versionInfo = (specDrift?.storedVersion && specDrift?.newVersion && specDrift.storedVersion !== specDrift.newVersion)
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
: '';
if (activeError) {
return {
variant: 'danger',
@@ -127,19 +119,10 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
buttons: ['review']
};
}
if (specDrift?.storedSpecMissing && lastSyncDate) {
return {
variant: 'warning',
title: 'Last synced spec not found',
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes..',
buttons: ['restore']
};
}
if (!hasDriftData) return null;
if (hasSpecUpdates && hasCollectionChanges) {
return {
variant: 'warning',
title: 'The API spec has new updates and the collection has changes',
title: `OpenAPI spec has new updates${versionInfo} and the collection has changes`,
subtitle: 'New or changed requests are available. Some collection changes may be overwritten.',
buttons: ['sync', 'changes']
};
@@ -147,11 +130,20 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
if (hasSpecUpdates) {
return {
variant: 'warning',
title: 'The API spec has new updates',
title: `OpenAPI spec has new updates${versionInfo}`,
subtitle: 'New or changed requests are available.',
buttons: ['sync']
};
}
if (specDrift?.storedSpecMissing && lastSyncDate) {
return {
variant: 'warning',
title: 'Last synced spec not found',
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track collection changes.',
buttons: ['spec-details']
};
}
if (!hasDriftData) return null;
if (hasCollectionChanges) {
return {
variant: 'muted',
@@ -160,14 +152,8 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
buttons: ['changes']
};
}
// return {
// variant: 'success',
// title: 'Collection is in sync with the spec',
// subtitle: null,
// buttons: []
// };
return null;
}, [activeError, fileNotFound, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, lastSyncDate]);
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
return (
<div className="overview-section">
@@ -179,12 +165,6 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
? <IconCheck size={16} className="status-check-icon" />
: <div className={`status-dot ${bannerState.variant}`} />}
<span className="banner-title">{bannerState.title}</span>
{bannerState.showBadge && (
<StatusBadge status="info" radius="full">{specUpdatesPending} {specUpdatesPending === 1 ? 'spec update' : 'spec updates'}</StatusBadge>
)}
{bannerState.showChangesBadge && (
<StatusBadge status="warning" radius="full">{changedInCollection} {changedInCollection === 1 ? 'collection change' : 'collection changes'}</StatusBadge>
)}
</div>
{bannerState.subtitle && (
<p className="banner-subtitle">{bannerState.subtitle}</p>
@@ -207,9 +187,9 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
Review and Sync Collection
</Button>
)}
{bannerState.buttons.includes('restore') && (
<Button size="sm" onClick={() => onTabSelect('spec-updates')}>
Restore Spec File
{bannerState.buttons.includes('spec-details') && (
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
Go to Spec Updates
</Button>
)}
{bannerState.buttons.includes('open-settings') && (

View File

@@ -1,11 +1,13 @@
import { useRef, useEffect } from 'react';
import { useRef, useEffect, useState } from 'react';
import { useTheme } from 'providers/Theme/index';
import { IconLoader2 } from '@tabler/icons';
import Modal from 'components/Modal';
import StatusBadge from 'ui/StatusBadge';
const SpecDiffModal = ({ specDrift, onClose }) => {
const diffRef = useRef(null);
const { displayedTheme } = useTheme();
const [isRendering, setIsRendering] = useState(true);
const addedCount = specDrift?.added?.length || 0;
const modifiedCount = specDrift?.modified?.length || 0;
@@ -17,7 +19,11 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
useEffect(() => {
const { Diff2Html } = window;
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) return;
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
setIsRendering(false);
return;
}
setIsRendering(true);
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
drawFileList: false,
matching: 'lines',
@@ -29,6 +35,7 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
});
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
diffRef.current.innerHTML = diffHtml;
setIsRendering(false);
}, [displayedTheme, specDrift?.unifiedDiff]);
return (
@@ -40,8 +47,8 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
>
<div className="spec-diff-modal">
<div className="spec-diff-badges">
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{modifiedCount > 0 && <StatusBadge status="info">Updated: {modifiedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>
@@ -60,7 +67,13 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
<span className="diff-column-label">Updated Spec</span>
</div>
<div ref={diffRef}></div>
{isRendering && (
<div className="text-diff-loading">
<IconLoader2 className="animate-spin" size={20} strokeWidth={1.5} />
<span>Loading diff...</span>
</div>
)}
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
</>
) : (
<div className="text-diff-empty">No text diff available.</div>

View File

@@ -2,9 +2,10 @@ import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import {
IconCheck,
IconRefresh
IconRefresh,
IconAlertTriangle,
IconClock
} from '@tabler/icons';
import moment from 'moment';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import ConfirmSyncModal from '../ConfirmSyncModal';
@@ -23,41 +24,37 @@ const SpecStatusSection = ({
const {
isSyncing, showConfirmModal, confirmGroups,
handleSyncNow, handleApplySync, cancelConfirmModal, handleConfirmModalSync
handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync
} = useSyncFlow({
collection, specDrift, remoteDrift, collectionDrift,
sourceUrl, setError, checkForUpdates: onCheck
setError, checkForUpdates: onCheck
});
const lastSyncedAt = openApiSyncConfig?.lastSyncDate;
const hasRemoteUpdates = remoteDrift && (
(remoteDrift.missing?.length || 0)
+ (remoteDrift.modified?.length || 0)
+ (remoteDrift.localOnly?.length || 0)
) > 0;
const bannerState = useMemo(() => {
if (fileNotFound) {
return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] };
}
if (error || specDrift?.isValid === false) {
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: [] };
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] };
}
if (!specDrift) {
return null;
// TODO: re-enable success banner
// if (!lastSyncedAt) return null;
// return {
// variant: 'success', message: 'Spec is up to date', actions: [],
// version: storedSpec?.info?.version,
// lastChecked: moment(lastCheckedAt || lastSyncedAt).fromNow()
// };
}
if (specDrift.storedSpecMissing) {
if (!lastSyncedAt) {
return { variant: 'warning', message: 'Initial sync required — your collection differs from the spec', actions: [] };
}
if (specDrift.hasRemoteChanges) {
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
}
return { variant: 'warning', message: 'Last synced spec not found — Restore the latest spec from the source to track future changes.', actions: [] };
if (specDrift.storedSpecMissing && !hasRemoteUpdates) {
return null;
}
if (specDrift.hasRemoteChanges) {
const hasEndpointUpdates = specDrift.storedSpecMissing
? hasRemoteUpdates
: (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
if (hasEndpointUpdates) {
const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion)
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
: '';
@@ -66,13 +63,8 @@ const SpecStatusSection = ({
changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 }
};
}
// return {
// variant: 'success', message: 'Spec is up to date', actions: [],
// version: specDrift.newVersion || storedSpec?.info?.version || specDrift.storedVersion,
// lastChecked: lastCheckedAt ? moment(lastCheckedAt).fromNow() : 'just now'
// };
return null;
}, [isLoading, fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt]);
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]);
return (
<>
{bannerState && (
@@ -94,16 +86,13 @@ const SpecStatusSection = ({
</span>
{bannerState.changes && (
<span className="banner-details">
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="warning" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
{bannerState.changes.added > 0 && <StatusBadge key="added" status="success" radius="full">{bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added</StatusBadge>}
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="info" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
{bannerState.changes.removed > 0 && <StatusBadge key="removed" status="danger" radius="full">{bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed</StatusBadge>}
</span>
)}
</div>
<div className="banner-actions">
{bannerState.actions.includes('quick-sync') && (
<Button size="xs" onClick={handleSyncNow}>Restore Spec File</Button>
)}
{bannerState.actions.includes('open-settings') && (
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
Update connection settings
@@ -114,16 +103,22 @@ const SpecStatusSection = ({
</div>
)}
{specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate ? (
{(error || fileNotFound || specDrift?.isValid === false) ? (
<div className="sync-review-empty-state mt-5">
<IconRefresh size={40} className="empty-state-icon" />
<h4>Last Synced Spec not found in storage</h4>
<p>The last synced spec is missing in the storage. Restore the latest spec from the source to track future changes.</p>
<Button className="mt-4" color="warning" onClick={handleSyncNow} loading={isSyncing}>
<IconAlertTriangle size={40} className="empty-state-icon" />
<h4>Unable to check for updates</h4>
<p>Fix the connection issue above and check again.</p>
</div>
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate && !hasRemoteUpdates ? (
<div className="sync-review-empty-state mt-5">
<IconCheck size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The spec endpoints have not been updated since the last sync. You can restore the spec file to track local collection changes.</p>
<Button className="mt-4" color="warning" onClick={handleRestoreSpec} loading={isSyncing}>
Restore Spec File
</Button>
</div>
) : remoteDrift && (
) : (
<div className="mt-5">
<SyncReviewPage
specDrift={specDrift}
@@ -133,6 +128,7 @@ const SpecStatusSection = ({
collectionUid={collection.uid}
newSpec={specDrift?.newSpec}
isSyncing={isSyncing}
isLoading={isLoading}
onApplySync={handleApplySync}
/>
</div>

View File

@@ -625,7 +625,7 @@ const StyledWrapper = styled.div`
.settings-label {
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.subtext0};
color: ${(props) => props.theme.text};
display: block;
margin-bottom: 5px;
}
@@ -670,7 +670,7 @@ const StyledWrapper = styled.div`
.toggle-description {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
margin-top: 2px;
}
@@ -970,7 +970,7 @@ const StyledWrapper = styled.div`
&.type-local-only { background: ${(props) => props.theme.colors.text.muted}; }
&.type-in-sync { background: ${(props) => props.theme.colors.text.green}; }
&.type-conflict { background: ${(props) => props.theme.colors.text.danger}; }
&.type-spec-modified { background: ${(props) => props.theme.colors.text.info}; }
&.type-spec-modified { background: ${(props) => props.theme.colors.text.warning}; }
&.type-collection-drift { background: ${(props) => props.theme.colors.text.warning}; }
}
@@ -988,8 +988,8 @@ const StyledWrapper = styled.div`
height: 1.25rem;
padding: 0 0.3rem;
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.subtext0};
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.colors.text.subtext1};
background: ${(props) => props.theme.background.surface1};
border-radius: 999px;
}
@@ -1251,7 +1251,6 @@ const StyledWrapper = styled.div`
.disconnect-modal {
.disconnect-message {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
margin-bottom: 1.5rem;
}
@@ -1281,7 +1280,7 @@ const StyledWrapper = styled.div`
.action-confirm-modal {
.confirm-message {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
line-height: 1.5;
margin-bottom: 1.5rem;
}
@@ -1504,11 +1503,15 @@ const StyledWrapper = styled.div`
.text-diff-container {
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.border.border1};
overflow: hidden;
overflow: auto;
.diff-column-headers {
display: flex;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
position: sticky;
top: 0;
z-index: 2;
background: ${(props) => props.theme.bg};
.diff-column-label {
flex: 1;
@@ -1640,6 +1643,16 @@ const StyledWrapper = styled.div`
}
}
.text-diff-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
.text-diff-empty {
padding: 2rem;
text-align: center;
@@ -1662,8 +1675,9 @@ const StyledWrapper = styled.div`
}
.spec-diff-body {
max-height: calc(80vh - 140px);
overflow: auto;
.text-diff-container {
max-height: calc(80vh - 140px);
}
}
}
@@ -1721,6 +1735,15 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.status.info.text};
background: ${(props) => props.theme.status.info.background};
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.spinner-icon {
animation: spin 1s linear infinite;
}
}
.sync-review-body {
@@ -2190,7 +2213,7 @@ const StyledWrapper = styled.div`
align-self: stretch;
gap: 2px;
padding: 2px;
background: ${(props) => props.theme.background.surface2};
background: ${(props) => props.theme.background.surface1};
border-radius: ${(props) => props.theme.border.radius.md};
}
@@ -2198,7 +2221,7 @@ const StyledWrapper = styled.div`
padding: 0 0.65rem;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
background: transparent;
border: none;
border-radius: calc(${(props) => props.theme.border.radius.md} - 3px);
@@ -2278,6 +2301,26 @@ const StyledWrapper = styled.div`
gap: 0.5rem;
flex-shrink: 0;
}
.beta-feedback-inline {
margin-top: 2rem;
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
.beta-feedback-link {
background: none;
border: none;
padding: 0;
color: ${(props) => props.theme.status.info.text};
cursor: pointer;
font-size: inherit;
text-decoration: underline;
&:hover {
opacity: 0.8;
}
}
}
`;
export default StyledWrapper;

View File

@@ -6,7 +6,7 @@ import {
IconArrowRight,
IconArrowsDiff,
IconInfoCircle,
IconRefresh
IconLoader2
} from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
@@ -28,8 +28,18 @@ import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'provide
* - specRemovedEndpoints: removed from spec, still in collection
*/
const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => {
const specAddedEndpoints = remoteDrift.missing || [];
const specRemovedEndpoints = remoteDrift.localOnly || [];
// Only show endpoints as "New in Spec" if they were actually added to the spec
// (i.e., they appear in specDrift.added). Endpoints the user deleted locally that
// still exist in both stored and remote spec should not appear here — they belong
// in "Collection Changes" only.
const specAddedIds = new Set((specDrift?.added || []).map((ep) => ep.id));
const specAddedEndpoints = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
// Only show endpoints as "Removed from Spec" if they were actually in the stored spec
// (i.e., they appear in specDrift.removed). Locally-added endpoints that were never in
// the spec should not appear here — they belong in "Collection Changes" only.
const specRemovedIds = new Set((specDrift?.removed || []).map((ep) => ep.id));
const specRemovedEndpoints = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
// Build lookup sets to determine who changed each modified endpoint
const specModifiedIds = new Set((specDrift?.modified || []).map((ep) => ep.id));
@@ -73,6 +83,7 @@ const SyncReviewPage = ({
collectionUid,
newSpec,
isSyncing,
isLoading,
onApplySync
}) => {
const dispatch = useDispatch();
@@ -153,10 +164,7 @@ const SyncReviewPage = ({
// Accepted — changes that will be applied
addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted));
addGroup('Endpoints to update', 'update', [
...specUpdatedEndpoints.filter(isAccepted),
...localUpdatedEndpoints.filter(isAccepted)
]);
addGroup('Endpoints to update', 'update', specUpdatedEndpoints.filter(isAccepted));
addGroup('Endpoints to delete', 'remove', specRemovedEndpoints.filter(isAccepted));
// Skipped — changes that will be preserved as-is
@@ -166,7 +174,7 @@ const SyncReviewPage = ({
addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep)));
return groups;
}, [specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, decisions]);
}, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]);
const handleConfirmApply = () => {
setShowConfirmation(false);
@@ -186,7 +194,6 @@ const SyncReviewPage = ({
onApplySync({
endpointDecisions: decisions,
removedIds: [],
localOnlyIds,
// Pass filtered categorized endpoints for performSync to construct the right backend diff
newToCollection: filteredAddedEndpoints,
@@ -250,9 +257,19 @@ const SyncReviewPage = ({
<div className="sync-review-body">
{!hasRemoteUpdates ? (
<div className="sync-review-empty-state">
<IconRefresh size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The collection matches the latest spec. Nothing to sync.</p>
{isLoading ? (
<>
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
<h4>Checking for updates</h4>
<p>Comparing your last synced spec with the latest spec...</p>
</>
) : (
<>
<IconCheck size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The spec endpoints have not been updated since the last sync.</p>
</>
)}
</div>
) : (
<div className="endpoints-review-sections">
@@ -264,7 +281,7 @@ const SyncReviewPage = ({
title="Updated in Spec"
type="spec-modified"
endpoints={specUpdatedEndpoints}
defaultExpanded={hasConflicts}
defaultExpanded={true}
expandableLayout
subtitle="The spec has updates for these endpoints"
headerExtra={conflictCount > 0 ? (
@@ -300,7 +317,7 @@ const SyncReviewPage = ({
title="New in Spec"
type="added"
endpoints={specAddedEndpoints}
defaultExpanded={false}
defaultExpanded={true}
expandableLayout
subtitle="New endpoints from the spec"
collectionUid={collectionUid}
@@ -324,7 +341,7 @@ const SyncReviewPage = ({
title="Removed from Spec"
type="removed"
endpoints={specRemovedEndpoints}
defaultExpanded={false}
defaultExpanded={true}
expandableLayout
subtitle="These endpoints are in your collection but not in the spec"
collectionUid={collectionUid}

View File

@@ -3,11 +3,12 @@ import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { clearCollectionState, setCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync';
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isHttpUrl } from 'utils/url/index';
import { flattenItems } from 'utils/collections/index';
import { formatIpcError } from 'utils/common/error';
import { countEndpoints } from '../utils';
const useOpenAPISync = (collection) => {
const dispatch = useDispatch();
@@ -29,6 +30,16 @@ const useOpenAPISync = (collection) => {
const isConfigured = !!openApiSyncConfig?.sourceUrl;
const updateStoredSpec = (spec) => {
setStoredSpec(spec);
dispatch(setStoredSpecMeta({
collectionUid: collection.uid,
title: spec?.info?.title || null,
version: spec?.info?.version || null,
endpointCount: spec ? countEndpoints(spec) : null
}));
};
// Flatten collection items including nested items in folders
const allHttpItems = useMemo(() => {
return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request');
@@ -77,6 +88,7 @@ const useOpenAPISync = (collection) => {
const prevItemCountRef = useRef(httpItemCount);
const isDriftLoadingRef = useRef(false);
const specDriftRef = useRef(specDrift);
const loadCollectionDrift = async ({ clear = false } = {}) => {
if (isDriftLoadingRef.current && !clear) return;
@@ -86,8 +98,7 @@ const useOpenAPISync = (collection) => {
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig
collectionPath: collection.pathname
});
if (!result.error) {
@@ -113,6 +124,7 @@ const useOpenAPISync = (collection) => {
setFileNotFound(false);
setSpecDrift(null);
setRemoteDrift(null);
setCollectionDrift(null);
try {
const { ipcRenderer } = window;
@@ -135,9 +147,7 @@ const useOpenAPISync = (collection) => {
}
setSpecDrift(result);
if (result.storedSpec) {
setStoredSpec(result.storedSpec);
}
updateStoredSpec(result.storedSpec || null);
// Update Redux store so toolbar status stays in sync
dispatch(setCollectionUpdate({
@@ -151,7 +161,6 @@ const useOpenAPISync = (collection) => {
if (result.newSpec) {
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig,
compareSpec: result.newSpec
});
if (remoteComparison.error) {
@@ -211,11 +220,11 @@ const useOpenAPISync = (collection) => {
try {
const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl });
if (specType !== 'openapi') {
setError('The URL does not point to a valid OpenAPI specification');
setError('The URL does not point to a valid OpenAPI 3.x specification');
return;
}
} catch {
setError('The URL does not point to a valid OpenAPI specification');
setError('The URL does not point to a valid OpenAPI 3.x specification');
return;
}
}
@@ -256,7 +265,6 @@ const useOpenAPISync = (collection) => {
if (result.newSpec) {
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
brunoConfig: collection.brunoConfig,
compareSpec: result.newSpec
});
@@ -269,8 +277,7 @@ const useOpenAPISync = (collection) => {
// Collection matches — save spec file silently to complete setup
await ipcRenderer.invoke('renderer:save-openapi-spec', {
collectionPath: collection.pathname,
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2),
sourceUrl: trimmedUrl
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2)
});
}
}
@@ -289,7 +296,6 @@ const useOpenAPISync = (collection) => {
const { ipcRenderer } = window;
await ipcRenderer.invoke('renderer:remove-openapi-sync-config', {
collectionPath: collection.pathname,
sourceUrl: openApiSyncConfig?.sourceUrl || sourceUrl,
deleteSpecFile: true
});
setSourceUrl('');
@@ -314,8 +320,30 @@ const useOpenAPISync = (collection) => {
}
};
// Reload drift — passed to useEndpointActions so it can refresh after actions
const reloadDrift = () => loadCollectionDrift({ clear: true });
// Keep ref in sync so reloadDrift always reads the latest specDrift
specDriftRef.current = specDrift;
// Reload both drifts — passed to useEndpointActions so it can refresh after actions.
// Uses specDriftRef to avoid stale closure over specDrift state.
const reloadDrift = async () => {
await loadCollectionDrift({ clear: true });
// Refresh remoteDrift if we have a remote spec cached from the last check
const currentSpecDrift = specDriftRef.current;
if (currentSpecDrift?.newSpec) {
try {
const { ipcRenderer } = window;
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
compareSpec: currentSpecDrift.newSpec
});
if (!remoteComparison.error) {
setRemoteDrift(remoteComparison);
}
} catch (err) {
console.error('Error reloading remote drift:', err);
}
}
};
// Save connection settings from the modal
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
@@ -328,11 +356,11 @@ const useOpenAPISync = (collection) => {
try {
({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl }));
} catch {
toast.error('The URL does not point to a valid OpenAPI specification');
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
throw new Error('Invalid OpenAPI specification');
}
if (specType !== 'openapi') {
toast.error('The URL does not point to a valid OpenAPI specification');
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
throw new Error('Invalid OpenAPI specification');
}
}
@@ -342,7 +370,6 @@ const useOpenAPISync = (collection) => {
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
collectionPath: collection.pathname,
oldSourceUrl: openApiSyncConfig?.sourceUrl,
config: {
sourceUrl: newUrl,
autoCheck,

View File

@@ -6,7 +6,7 @@ import { formatIpcError } from 'utils/common/error';
const useSyncFlow = ({
collection, specDrift, remoteDrift, collectionDrift,
sourceUrl, setError, checkForUpdates
setError, checkForUpdates
}) => {
const dispatch = useDispatch();
@@ -14,13 +14,13 @@ const useSyncFlow = ({
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const performSync = async (selections = { removedIds: [], localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
setShowConfirmModal(false);
setIsSyncing(true);
setError(null);
const {
removedIds = [], localOnlyIds = [], endpointDecisions: decisions = {},
localOnlyIds = [], endpointDecisions: decisions = {},
newToCollection, specUpdates, resolvedConflicts, localChangesToReset
} = selections;
@@ -49,9 +49,7 @@ const useSyncFlow = ({
// Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly
filteredDiff = {
...specDrift,
removed: removedIds.length > 0
? (specDrift?.removed || []).filter((ep) => removedIds.includes(ep.id))
: []
removed: [] // Removals handled via localOnlyToRemove
};
localOnlyToRemove = localOnlyIds.length > 0
@@ -67,9 +65,8 @@ const useSyncFlow = ({
await ipcRenderer.invoke('renderer:apply-openapi-sync', {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl: sourceUrl.trim(),
addNewRequests: mode !== 'spec-only',
removeDeletedRequests: removedIds.length > 0 || localOnlyIds.length > 0,
removeDeletedRequests: localOnlyIds.length > 0,
diff: filteredDiff,
localOnlyToRemove,
driftedToReset,
@@ -113,10 +110,28 @@ const useSyncFlow = ({
setPendingSyncMode(null);
};
// Only treat endpoints as spec changes if they actually changed in the spec
// (not locally-added/deleted endpoints that were never in or removed from the spec)
const specAddedIds = useMemo(() => {
return new Set((specDrift?.added || []).map((ep) => ep.id));
}, [specDrift]);
const specRemovedIds = useMemo(() => {
return new Set((specDrift?.removed || []).map((ep) => ep.id));
}, [specDrift]);
const handleRestoreSpec = () => {
const localOnlyIds = (remoteDrift?.localOnly || [])
.filter((ep) => specRemovedIds.has(ep.id))
.map((ep) => ep.id);
performSync({ localOnlyIds, endpointDecisions: {} }, 'sync');
};
const handleConfirmModalSync = () => {
const localOnlyIds = (remoteDrift?.localOnly || []).map((ep) => ep.id);
const localOnlyIds = (remoteDrift?.localOnly || [])
.filter((ep) => specRemovedIds.has(ep.id))
.map((ep) => ep.id);
performSync({
removedIds: [],
localOnlyIds,
endpointDecisions: {}
}, pendingSyncMode || 'sync');
@@ -125,21 +140,23 @@ const useSyncFlow = ({
const confirmGroups = useMemo(() => {
if (!remoteDrift) return [];
const groups = [];
if (remoteDrift.missing?.length > 0) {
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: remoteDrift.missing });
const actuallyAdded = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
if (actuallyAdded.length > 0) {
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: actuallyAdded });
}
if (remoteDrift.modified?.length > 0) {
groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified });
}
if (remoteDrift.localOnly?.length > 0) {
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: remoteDrift.localOnly });
const actuallyRemoved = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
if (actuallyRemoved.length > 0) {
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: actuallyRemoved });
}
return groups;
}, [remoteDrift]);
}, [remoteDrift, specAddedIds, specRemovedIds]);
return {
isSyncing, showConfirmModal, confirmGroups,
handleSyncNow,
handleSyncNow, handleRestoreSpec,
handleApplySync, cancelConfirmModal, handleConfirmModalSync
};
};

View File

@@ -3,7 +3,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { v4 as uuid } from 'uuid';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { setTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
import { IconClock } from '@tabler/icons';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import StyledWrapper from './StyledWrapper';
import OpenAPISyncHeader from './OpenAPISyncHeader';
@@ -130,48 +129,35 @@ const OpenAPISyncTab = ({ collection }) => {
remoteDrift={remoteDrift}
onTabSelect={setActiveTab}
error={error}
isLoading={isLoading}
fileNotFound={fileNotFound}
onOpenSettings={() => setShowSettingsModal(true)}
/>
<p className="beta-feedback-inline">
OpenAPI Sync is in Beta we'd love to hear your feedback and suggestions.{' '}
<button
type="button"
className="beta-feedback-link"
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
>
Share feedback
</button>
</p>
</div>
)}
{activeTab === 'collection-changes' && (
<div className="sync-tab-content">
{collectionDrift && !collectionDrift.noStoredSpec ? (
<CollectionStatusSection
collection={collection}
collectionDrift={collectionDrift}
reloadDrift={reloadDrift}
specDrift={specDrift}
storedSpec={storedSpec}
lastSyncDate={openApiSyncConfig?.lastSyncDate}
onOpenEndpoint={openEndpointInTab}
/>
) : !isDriftLoading && !isLoading && (
<>
<div className="spec-update-banner warning">
<div className="banner-left">
<div className="status-dot warning" />
<span className="banner-title">
{openApiSyncConfig?.lastSyncDate
? 'Last synced spec is required to show collection changes. Restore the latest spec from the source to track future changes..'
: 'Collection changes will be available after the initial sync'}
</span>
</div>
</div>
<div className="sync-review-empty-state mt-5">
<IconClock size={40} className="empty-state-icon" />
<h4>{openApiSyncConfig?.lastSyncDate ? 'Last Synced Spec missing from storage' : 'Waiting for initial sync'}</h4>
<p>{openApiSyncConfig?.lastSyncDate
? 'Restore the latest spec from the source to track future changes..'
: 'Once you sync your collection with the spec, changes will appear here.'}
</p>
</div>
</>
)}
<CollectionStatusSection
collection={collection}
collectionDrift={collectionDrift}
reloadDrift={reloadDrift}
specDrift={specDrift}
storedSpec={storedSpec}
lastSyncDate={openApiSyncConfig?.lastSyncDate}
onOpenEndpoint={openEndpointInTab}
isLoading={isDriftLoading || isLoading}
onTabSelect={setActiveTab}
/>
</div>
)}
@@ -195,6 +181,7 @@ const OpenAPISyncTab = ({ collection }) => {
)}
</>
)}
</div>
{showSettingsModal && (

View File

@@ -0,0 +1,16 @@
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
/**
* Count the number of HTTP endpoints in an OpenAPI spec.
* Returns null if the spec has no paths (e.g. spec is null/undefined).
*/
export const countEndpoints = (spec) => {
if (!spec?.paths) return null;
let count = 0;
for (const path of Object.values(spec.paths)) {
for (const key of Object.keys(path)) {
if (HTTP_METHODS.includes(key.toLowerCase())) count++;
}
}
return count;
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
@@ -8,17 +8,19 @@ import debounce from 'lodash/debounce';
import toast from 'react-hot-toast';
import { IconFlask } from '@tabler/icons';
import get from 'lodash/get';
import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
/**
* Add beta features here.
* Example:
* {
* id: 'nodevm',
* label: 'Node VM Runtime',
* description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
* }
* UI metadata for beta features rendered in Preferences.
* IDs must match keys from utils/beta-features.js BETA_FEATURES.
*/
const BETA_FEATURES = [];
const BETA_FEATURES = [
{
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
label: 'OpenAPI Sync',
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
}
];
const Beta = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
@@ -45,6 +47,7 @@ const Beta = ({ close }) => {
const betaSchema = generateValidationSchema();
const formik = useFormik({
enableReinitialize: true,
initialValues: generateInitialValues(),
validationSchema: betaSchema,
onSubmit: async (values) => {
@@ -61,22 +64,28 @@ const Beta = ({ close }) => {
dispatch(
savePreferences({
...preferences,
beta: newBetaPreferences
beta: {
...preferences.beta,
...newBetaPreferences
}
})
)
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
}, [dispatch, preferences]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((values) => {
betaSchema.validate(values, { abortEarly: true })
.then((validatedValues) => {
handleSave(validatedValues);
handleSaveRef.current(validatedValues);
})
.catch((error) => {
});
}, 500),
[handleSave, betaSchema]
[betaSchema]
);
// Auto-save when form values change
@@ -85,7 +94,7 @@ const Beta = ({ close }) => {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);

View File

@@ -68,7 +68,7 @@ const Cache = () => {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);

View File

@@ -38,11 +38,14 @@ const Font = () => {
});
}, [dispatch, preferences]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((font, fontSize) => {
handleSave(font, fontSize);
handleSaveRef.current(font, fontSize);
}, 500),
[handleSave]
[]
);
useEffect(() => {
@@ -52,7 +55,7 @@ const Font = () => {
}
debouncedSave(codeFont, codeFontSize);
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [codeFont, codeFontSize, debouncedSave]);

View File

@@ -127,16 +127,19 @@ const General = () => {
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
}, [dispatch, preferences]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((values) => {
preferencesSchema.validate(values, { abortEarly: true })
.then((validatedValues) => {
handleSave(validatedValues);
handleSaveRef.current(validatedValues);
})
.catch((error) => {
});
}, 500),
[handleSave]
[]
);
useEffect(() => {
@@ -144,7 +147,7 @@ const General = () => {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);

View File

@@ -4,6 +4,8 @@ const StyledWrapper = styled.div`
min-height: 0;
max-height: calc(100% - 30px);
max-width: 80%;
display: flex;
flex-direction: column;
@@ -17,24 +19,119 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0px;
}
.reset-all-btn {
.section-actions {
display: flex;
align-items: center;
background: transparent;
border: 1px solid ${(props) => props.theme.table.border};
border-radius: 6px;
padding: 4px 4px;
cursor: pointer;
color: ${(props) => props.theme.text};
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
gap: 10px;
flex-shrink: 0;
}
&:hover {
background: ${(props) => props.theme.button.secondary.hoverBg};
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
.section-actions-divider {
width: 1px;
height: 18px;
background: ${(props) => props.theme.input.border};
opacity: 0.9;
}
.section-divider {
height: 1px;
background: ${(props) => props.theme.input.border};
margin: 10px 0;
}
.tables-container {
overflow-y: auto;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
scrollbar-width: none;
-ms-overflow-style: none;
&.tables-disabled {
opacity: 0.45;
pointer-events: none;
user-select: none;
}
}
.table-container {
min-height: 0;
overflow: hidden;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
}
table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
font-size: ${(props) => props.theme.font.size.base};
}
thead {
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
user-select: none;
td {
padding: 5px 10px !important;
border: none !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
vertical-align: middle;
}
}
thead td:first-child,
tbody td:first-child {
width: 35%;
}
thead td:last-child,
tbody td:last-child {
width: 45%;
}
tbody {
tr {
transition: background 0.1s ease;
height: 30px;
td {
padding: 0 10px !important;
border: none !important;
vertical-align: middle;
background: transparent;
transition: background 0.15s ease;
}
}
tr:hover:not(.row-editing) td {
background: ${(props) => props.theme.sidebar.bg};
cursor: pointer;
}
tr.row-editing td {
cursor: default;
}
tr.section-heading-row td {
font-weight: 600;
padding: 6px 10px !important;
user-select: none;
}
tr.section-heading-row:hover td {
background: transparent;
cursor: default;
}
tr.section-last-row td {
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
}
}
@@ -71,35 +168,110 @@ const StyledWrapper = styled.div`
}
.shortcut-input {
width: 200px;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
min-width: 200px;
max-width: 200px;
flex-shrink: 0;
caret-color: ${(props) => props.theme.table.input.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: none;
outline: none;
background: transparent;
font-family: monospace;
color: ${(props) => props.theme.table.input.color};
cursor: pointer;
}
&:hover {
opacity: 0.85;
}
.shortcut-input--editing {
outline: 1px solid #E4AE49;
border-radius: 4px;
min-width: 100%;
max-width: 100%;
padding: 0 8px;
caret-color: ${(props) => props.theme.text};
}
&:focus {
opacity: 1;
}
.shortcut-input--error.shortcut-input--editing {
outline: 1px solid #CE4F3B;
min-width: 100%;
max-width: 100%;
}
&::placeholder {
opacity: 0.5;
}
.shortcut-input--readonly {
cursor: default;
}
.shortcut-text {
font-size: 12px;
color: ${(props) => props.theme.table.input.color};
}
.shortcut-pills {
display: inline-flex;
align-items: center;
gap: 4px;
}
.shortcut-separator {
color: ${(props) => props.theme.table.thead.color};
margin: 0 4px;
font-size: 12px;
}
.keycap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 22px;
padding: 2px;
border-radius: 3px;
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.input.color};
font-size: 12px;
font-weight: 500;
line-height: 1;
}
tbody tr.row-success td {
background: #2E8A540F;
}
tbody tr.row-error td {
background: #D32F2F0F;
}
.success-icon {
color: #2E8A54;
display: inline-flex;
align-items: center;
}
.error-icon {
color: #CE4F3B;
display: inline-flex;
align-items: center;
}
.input-error-icon {
color: #CE4F3B;
display: inline-flex;
align-items: center;
margin-left: auto;
flex-shrink: 0;
}
@keyframes blink-caret {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.editing-caret {
display: inline-block;
width: 1px;
height: 12px;
background: ${(props) => props.theme.text};
margin-left: 1px;
vertical-align: middle;
animation: blink-caret 1s step-end infinite;
}
.edit-btn {
@@ -117,83 +289,40 @@ const StyledWrapper = styled.div`
.reset-btn {
background: transparent;
border: none;
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.table.thead.color};
border-radius: 8px;
padding: 0px;
border-radius: 6px;
padding: 0px 6px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.action-btn {
background: transparent;
color: ${(props) => props.theme.table.thead.color};
border-radius: 6px;
padding: 4px;
cursor: pointer;
}
.pencil-icon {
color: ${(props) => props.theme.table.thead.color};
display: inline-flex;
align-items: center;
opacity: 0.5;
}
.shortcut-input--error {
opacity: 1;
}
.tooltip-mod.tooltip-mod--error{
color: ${(props) => props.theme.status.danger.text} !important;
.tooltip-mod.tooltip-mod--error {
color: ${(props) => props.theme.status.danger.text} !important;
}
.table-container {
margin-bottom: 24px;
min-height: 0;
overflow-y: auto;
border-radius: 8px;
border-top: 1px solid ${(props) => props.theme.table.border};
border-bottom: 1px solid ${(props) => props.theme.table.border};
&::-webkit-scrollbar {
width: 0;
height: 0;
}
scrollbar-width: none;
-ms-overflow-style: none;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
thead th:first-child,
tbody td:first-child {
width: 35%;
}
thead th:last-child,
tbody td:last-child {
width: 45%;
}
thead th {
position: sticky;
top: 0;
z-index: 5;
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
font-weight: 500;
padding: 10px;
text-align: left;
border-left: 1px solid ${(props) => props.theme.table.border};
border-right: 1px solid ${(props) => props.theme.table.border};
border-bottom: 1px solid ${(props) => props.theme.table.border};
box-shadow: 0 1px 0 ${(props) => props.theme.table.border};
}
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
border-top: 1px solid ${(props) => props.theme.table.border};
border-left: 1px solid ${(props) => props.theme.table.border};
border-right: 1px solid ${(props) => props.theme.table.border};
.empty-state {
padding: 12px 2px;
color: ${(props) => props.theme.text};
opacity: 0.8;
}
`;

View File

@@ -5,7 +5,7 @@ const StyledWrapper = styled.div`
flex-direction: column;
gap: 1rem;
width: 100%;
.settings-label {
width: 100px;
}
@@ -26,6 +26,57 @@ const StyledWrapper = styled.div`
}
}
.pac-mode-toggle {
display: inline-flex;
flex-shrink: 0;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.base};
overflow: hidden;
margin-right: 12px;
}
.pac-mode-btn {
height: 34px;
padding: 0.1rem 0.6rem;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
transition: background 0.12s, color 0.12s;
white-space: nowrap;
&.active {
background: ${(props) => props.theme.button.secondary.bg};
color: ${(props) => props.theme.button.secondary.color};
}
&:hover:not(.active) {
color: ${(props) => props.theme.text};
}
}
.pac-source-input {
width: 265px;
}
.pac-file-btn {
text-align: left;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pac-hint {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
margin-top: 4px;
padding-left: 100px;
}
.system-proxy-settings {
label {
color: ${(props) => props.theme.colors.text.yellow};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
@@ -17,7 +17,22 @@ const ProxySettings = ({ close }) => {
const proxySchema = Yup.object({
disabled: Yup.boolean().optional(),
inherit: Yup.boolean().required(),
source: Yup.string().oneOf(['manual', 'pac', 'inherit']).required(),
pac: Yup.object({
source: Yup.string()
.optional()
.test('pac-url', 'Specify a valid PAC URL', (value) => {
if (!value) return true;
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:' || u.protocol === 'file:';
} catch {
return false;
}
})
.max(2048)
.nullable()
}).optional(),
config: Yup.object({
protocol: Yup.string().required().oneOf(['http', 'https', 'socks4', 'socks5']),
hostname: Yup.string().max(1024),
@@ -39,7 +54,10 @@ const ProxySettings = ({ close }) => {
const formik = useFormik({
initialValues: {
disabled: preferences.proxy.disabled || false,
inherit: preferences.proxy.inherit || false,
source: preferences.proxy.source || 'manual',
pac: {
source: preferences.proxy.pac?.source || ''
},
config: {
protocol: preferences.proxy.config?.protocol || 'http',
hostname: preferences.proxy.config?.hostname || '',
@@ -75,41 +93,37 @@ const ProxySettings = ({ close }) => {
});
}, [dispatch, preferences, proxySchema]);
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
const debouncedSave = useCallback(
debounce((values) => {
onUpdate(values);
onUpdateRef.current(values);
}, 500),
[onUpdate]
[]
);
const [passwordVisible, setPasswordVisible] = useState(false);
const [proxyMode, setProxyMode] = useState(() => {
if (preferences.proxy.disabled) return 'off';
if (preferences.proxy.source === 'pac') return 'pac';
if (preferences.proxy.source === 'inherit') return 'inherit';
return 'manual';
});
const [pacInputMode, setPacInputMode] = useState(() =>
preferences.proxy.pac?.source?.startsWith('file://') ? 'file' : 'url'
);
useEffect(() => {
formik.setValues({
disabled: preferences.proxy.disabled || false,
inherit: preferences.proxy.inherit || false,
config: {
protocol: preferences.proxy.config?.protocol || 'http',
hostname: preferences.proxy.config?.hostname || '',
port: preferences.proxy.config?.port || '',
auth: {
disabled: preferences.proxy.config?.auth?.disabled || false,
username: preferences.proxy.config?.auth?.username || '',
password: preferences.proxy.config?.auth?.password || ''
},
bypassProxy: preferences.proxy.config?.bypassProxy || ''
}
});
}, [preferences]);
useEffect(() => {
if (formik.dirty) {
if (formik.dirty && formik.isValid) {
// Don't auto-save PAC mode until a URL or file is actually selected.
if (proxyMode === 'pac' && !formik.values.pac.source) return;
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, debouncedSave]);
}, [formik.values, formik.dirty, formik.isValid, debouncedSave, proxyMode]);
return (
<StyledWrapper>
@@ -125,10 +139,10 @@ const ProxySettings = ({ close }) => {
type="radio"
name="mode"
value="off"
checked={formik.values.disabled === true}
checked={proxyMode === 'off'}
onChange={(e) => {
setProxyMode('off');
formik.setFieldValue('disabled', true);
formik.setFieldValue('inherit', false);
}}
className="mr-1 cursor-pointer"
/>
@@ -138,11 +152,12 @@ const ProxySettings = ({ close }) => {
<input
type="radio"
name="mode"
value="on"
checked={formik.values.disabled === false && formik.values.inherit === false}
value="manual"
checked={proxyMode === 'manual'}
onChange={(e) => {
setProxyMode('manual');
formik.setFieldValue('disabled', false);
formik.setFieldValue('inherit', false);
formik.setFieldValue('source', 'manual');
}}
className="mr-1 cursor-pointer"
/>
@@ -152,24 +167,40 @@ const ProxySettings = ({ close }) => {
<input
type="radio"
name="mode"
value="system"
checked={formik.values.disabled === false && formik.values.inherit === true}
value="inherit"
checked={proxyMode === 'inherit'}
onChange={(e) => {
setProxyMode('inherit');
formik.setFieldValue('disabled', false);
formik.setFieldValue('inherit', true);
formik.setFieldValue('source', 'inherit');
}}
className="mr-1 cursor-pointer"
/>
System Proxy
</label>
<label className="flex items-center ml-4 cursor-pointer">
<input
type="radio"
name="mode"
value="pac"
checked={proxyMode === 'pac'}
onChange={(e) => {
setProxyMode('pac');
formik.setFieldValue('disabled', false);
formik.setFieldValue('source', 'pac');
}}
className="mr-1 cursor-pointer"
/>
PAC
</label>
</div>
</div>
{formik.values.disabled === false && formik.values.inherit === true ? (
{proxyMode === 'inherit' ? (
<div className="mb-3 pt-1 text-muted system-proxy-settings">
<SystemProxy />
</div>
) : null}
{formik.values.disabled === false && formik.values.inherit === false ? (
{proxyMode === 'manual' ? (
<>
<div className="mb-3 flex items-center">
<label className="settings-label" htmlFor="protocol">
@@ -350,6 +381,79 @@ const ProxySettings = ({ close }) => {
</div>
</>
) : null}
{proxyMode === 'pac' ? (
<>
<div className="mb-3">
<div className="flex items-center">
<label className="settings-label">PAC</label>
<div className="pac-mode-toggle">
<button
type="button"
className={`pac-mode-btn ${pacInputMode === 'url' ? 'active' : ''}`}
onClick={() => {
setPacInputMode('url');
formik.setFieldValue('pac.source', '');
}}
>
URL
</button>
<button
type="button"
className={`pac-mode-btn ${pacInputMode === 'file' ? 'active' : ''}`}
onClick={() => {
setPacInputMode('file');
formik.setFieldValue('pac.source', '');
}}
>
File
</button>
</div>
{pacInputMode === 'url' ? (
<input
id="pac.source"
type="text"
name="pac.source"
className="block textbox pac-source-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.pac.source || ''}
placeholder="https://example.com/proxy.pac"
/>
) : (
<button
type="button"
className="textbox pac-source-input pac-file-btn"
onClick={() => {
window.ipcRenderer
.invoke('renderer:browse-pac-file')
.then((fileUrl) => {
if (fileUrl) {
formik.setFieldValue('pac.source', fileUrl);
}
})
.catch(() => toast.error('Failed to open file picker'));
}}
>
{formik.values.pac.source
? decodeURIComponent(formik.values.pac.source.split('/').pop())
: 'Choose file...'}
</button>
)}
{formik.touched.pac?.source && formik.errors.pac?.source ? (
<div className="ml-3 text-red-500">{formik.errors.pac.source}</div>
) : null}
</div>
<p className="pac-hint">
{pacInputMode === 'url'
? 'Enter the URL to your PAC file'
: 'Supports .pac files for automatic proxy configuration'}
</p>
</div>
</>
) : null}
</form>
</StyledWrapper>
);

View File

@@ -38,7 +38,7 @@ const StyledWrapper = styled.div`
}
section.tab-panel {
max-height: calc(100% - 24px);
max-height: calc(100% - 55px);
overflow-y: auto;
flex-grow: 1;
padding: 12px;

View File

@@ -1,9 +1,10 @@
import React, { useCallback } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { moveAssertion, setRequestAssertions } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import SingleLineEditor from 'components/SingleLineEditor';
import AssertionOperator from './AssertionOperator';
import EditableTable from 'components/EditableTable';
@@ -54,8 +55,18 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator);
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
const assertionsWidths = focusedTab?.tableColumnWidths?.['assertions'] || {};
const handleColumnWidthsChange = (tableId, widths) => {
dispatch(updateTableColumnWidths({ uid: activeTabUid, tableId, widths }));
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleRun = () => dispatch(sendRequest(item, collection.uid));
@@ -157,6 +168,7 @@ const Assertions = ({ item, collection }) => {
return (
<StyledWrapper className="w-full">
<EditableTable
tableId="assertions"
columns={columns}
rows={assertions || []}
onChange={handleAssertionsChange}
@@ -164,6 +176,8 @@ const Assertions = ({ item, collection }) => {
reorderable={true}
onReorder={handleAssertionDrag}
testId="assertions-table"
columnWidths={assertionsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
/>
</StyledWrapper>
);

View File

@@ -47,6 +47,11 @@ const AuthMode = ({ item, collection }) => {
label: 'NTLM Auth',
onClick: () => onModeChange('ntlm')
},
{
id: 'oauth1',
label: 'OAuth 1.0',
onClick: () => onModeChange('oauth1')
},
{
id: 'oauth2',
label: 'OAuth 2.0',

View File

@@ -0,0 +1,90 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const Wrapper = styled.div`
.oauth1-icon-container {
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
}
label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.subtext1};
}
.oauth1-section-label {
color: ${(props) => props.theme.text};
}
.single-line-editor-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.oauth1-dropdown-selector {
font-size: ${(props) => props.theme.font.size.sm};
padding: 0.2rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.oauth1-dropdown-label {
width: fit-content;
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
.private-key-editor-wrapper {
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
max-width: 400px;
overflow: hidden;
}
input[type='checkbox'] {
cursor: pointer;
accent-color: ${(props) => props.theme.primary.solid};
}
.transition-transform {
transition: transform 0.15s ease;
}
.rotate-90 {
transform: rotate(90deg);
}
`;
export default Wrapper;

View File

@@ -0,0 +1,439 @@
import React, { useState } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import path from 'utils/common/path';
import { IconSettings, IconShieldLock, IconAdjustmentsHorizontal, IconCaretDown, IconChevronRight, IconFile, IconX, IconUpload } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import MultiLineEditor from 'components/MultiLineEditor';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import toast from 'react-hot-toast';
import { sendRequest, browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const signatureMethodLabels = {
'HMAC-SHA1': 'HMAC-SHA1',
'HMAC-SHA256': 'HMAC-SHA256',
'HMAC-SHA512': 'HMAC-SHA512',
'RSA-SHA1': 'RSA-SHA1',
'RSA-SHA256': 'RSA-SHA256',
'RSA-SHA512': 'RSA-SHA512',
'PLAINTEXT': 'PLAINTEXT'
};
const placementLabels = {
header: 'Header',
query: 'Query Params',
body: 'Body'
};
const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const oauth1 = get(request, 'auth.oauth1', {});
const [advancedOpen, setAdvancedOpen] = useState(false);
const { isSensitive } = useDetectSensitiveField(collection);
const consumerSecretSensitive = isSensitive(oauth1.consumerSecret);
const tokenSecretSensitive = isSensitive(oauth1.accessTokenSecret);
const privateKeySensitive = isSensitive(oauth1.privateKey);
const handleRun = item?.uid ? () => dispatch(sendRequest(item, collection.uid)) : undefined;
const handleSave = () => save();
const handleChange = (field, value) => {
dispatch(
updateAuth({
mode: 'oauth1',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oauth1,
[field]: value
}
})
);
};
const handlePrivateKeyChange = (val) => {
if (val && /^@file\(/.test(val.trim())) {
toast.error('File references should be added using the "Upload File" button below');
return;
}
handleChange('privateKey', val);
};
const handleBrowse = () => {
dispatch(browseFiles([], []))
.then((filePaths) => {
if (filePaths && filePaths.length > 0) {
let filePath = filePaths[0];
const collectionDir = collection.pathname;
filePath = path.relative(collectionDir, filePath);
dispatch(
updateAuth({
mode: 'oauth1',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oauth1,
privateKey: filePath,
privateKeyType: 'file'
}
})
);
}
})
.catch((error) => console.error(error));
};
const handleClearFile = () => {
dispatch(
updateAuth({
mode: 'oauth1',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
...oauth1,
privateKey: '',
privateKeyType: 'text'
}
})
);
};
const privateKeyValue = oauth1.privateKey || '';
const isFileRef = oauth1.privateKeyType === 'file';
const fileName = isFileRef ? path.basename(privateKeyValue) : '';
return (
<StyledWrapper className="mt-2 flex w-full gap-4 flex-col">
{/* Configuration Section */}
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
<IconSettings size={14} className="oauth1-icon" />
</div>
<span className="oauth1-section-label">
Configuration
</span>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Consumer Key</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.consumerKey || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('consumerKey', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
{!oauth1.signatureMethod?.startsWith('RSA-') && (
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Consumer Secret</label>
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oauth1.consumerSecret || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('consumerSecret', val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
isCompact
/>
{consumerSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-consumer-secret" warningMessage={consumerSecretSensitive.warningMessage} />}
</div>
</div>
)}
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Token</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.accessToken || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('accessToken', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Token Secret</label>
<div className="single-line-editor-wrapper flex-1 flex items-center">
<SingleLineEditor
value={oauth1.accessTokenSecret || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('accessTokenSecret', val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
isCompact
/>
{tokenSecretSensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-token-secret" warningMessage={tokenSecretSensitive.warningMessage} />}
</div>
</div>
{/* Signature Section */}
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
<IconShieldLock size={14} className="oauth1-icon" />
</div>
<span className="oauth1-section-label">
Signature
</span>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Signature Method</label>
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
<MenuDropdown
items={Object.entries(signatureMethodLabels).map(([value, label]) => ({
id: value,
label,
onClick: () => handleChange('signatureMethod', value)
}))}
selectedItemId={oauth1.signatureMethod}
placement="bottom-end"
>
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
{signatureMethodLabels[oauth1.signatureMethod] || 'HMAC-SHA1'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
{oauth1.signatureMethod?.startsWith('RSA-') && (
<div className="flex items-start gap-4 w-full">
<label className="block min-w-[140px] mt-1">Private Key</label>
{isFileRef ? (
<div className="private-key-editor-wrapper flex-1 flex items-center gap-2">
<IconFile size={16} className="oauth1-icon flex-shrink-0" />
<span className="truncate flex-1" title={privateKeyValue}>{fileName}</span>
<button
className="flex-shrink-0 oauth1-icon cursor-pointer"
onClick={handleClearFile}
title="Clear file"
type="button"
>
<IconX size={14} />
</button>
</div>
) : (
<div className="flex flex-1 flex-col gap-2">
<div className="private-key-editor-wrapper flex-1 flex items-center">
<MultiLineEditor
value={privateKeyValue}
theme={storedTheme}
onSave={handleSave}
onChange={handlePrivateKeyChange}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
allowNewlines={true}
/>
{privateKeySensitive.showWarning && <SensitiveFieldWarning fieldName="oauth1-private-key" warningMessage={privateKeySensitive.warningMessage} />}
</div>
<div className="flex flex-row gap-2">
<button
className="flex items-center gap-1 oauth1-icon cursor-pointer text-link"
onClick={handleBrowse}
title="Select file"
type="button"
>
<IconUpload size={14} />
<span className="text-xs">Upload File</span>
</button>
</div>
</div>
)}
</div>
)}
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Add Params To</label>
<div className="inline-flex items-center cursor-pointer oauth1-dropdown-selector">
<MenuDropdown
items={Object.entries(placementLabels).map(([value, label]) => ({
id: value,
label,
onClick: () => handleChange('placement', value)
}))}
selectedItemId={oauth1.placement}
placement="bottom-end"
>
<div className="flex items-center justify-end oauth1-dropdown-label select-none">
{placementLabels[oauth1.placement] || 'Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
{oauth1.placement === 'body' && (
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]"></label>
<span className="text-xs opacity-60">
Body placement requires a form-urlencoded body. Non-form payloads will be replaced with OAuth parameters.
</span>
</div>
)}
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]"></label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={oauth1.includeBodyHash || false}
onChange={(e) => handleChange('includeBodyHash', e.target.checked)}
/>
<label
className="block cursor-pointer"
onClick={(e) => {
e.preventDefault(); handleChange('includeBodyHash', !oauth1.includeBodyHash);
}}
>
Include Body Hash
</label>
</div>
</div>
{/* Advanced Section (collapsible) */}
<div
className="flex items-center gap-2.5 mt-2 cursor-pointer select-none"
onClick={() => setAdvancedOpen(!advancedOpen)}
>
<div className="flex items-center px-2.5 py-1.5 oauth1-icon-container rounded-md">
<IconAdjustmentsHorizontal size={14} className="oauth1-icon" />
</div>
<span className="oauth1-section-label">
Advanced
</span>
<IconChevronRight
size={14}
className={`oauth1-icon transition-transform ${advancedOpen ? 'rotate-90' : ''}`}
/>
</div>
{advancedOpen && (
<>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Callback URL</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.callbackUrl || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('callbackUrl', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Verifier</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.verifier || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('verifier', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Timestamp</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.timestamp || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('timestamp', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Nonce</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.nonce || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('nonce', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Version</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.version || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('version', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
<div className="flex items-center gap-4 w-full">
<label className="block min-w-[140px]">Realm</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
value={oauth1.realm || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('realm', val)}
onRun={handleRun}
collection={collection}
item={item}
isCompact
/>
</div>
</div>
</>
)}
</StyledWrapper>
);
};
export default OAuth1;

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