Compare commits

...

85 Commits

Author SHA1 Message Date
dependabot[bot]
c6dd60e2cc chore(deps-dev): bump webpack from 5.97.1 to 5.107.2
Bumps [webpack](https://github.com/webpack/webpack) from 5.97.1 to 5.107.2.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack/compare/v5.97.1...v5.107.2)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.102.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 12:22:19 +00:00
Pooja
59b4a16b79 fix(timeline): scope scripted requests to their own request (#8210)
* fix(timeline): scope scripted requests to their own request

* fix: oauth playwright test
2026-06-11 15:10:57 +05:30
sachin-bruno
377cdb488c fix(size/L): Preserve folder order from seq attribute (#8213) 2026-06-10 19:41:26 +05:30
sachin-thakur-bruno
79504ed729 fix(SSE-text/event-stream): sse response body is empty in res.getBody() for app and cli (#8212) 2026-06-10 18:40:47 +05:30
naman-bruno
6791e0a674 feat: integrate AIAssist for script editing (#8220) 2026-06-10 16:33:44 +05:30
Bijin A B
ed5f5c21cf Revert "fix: open panes at default size on expand from collapsed state (#8133)" (#8217) 2026-06-09 20:11:51 +05:30
gopu-bruno
280b856869 fix: open panes at default size on expand from collapsed state (#8133)
* fix(tabs): open panes at default size on expand from collapsed state

* chore: shorten comment in pane expand reducers

* test(tabs): add tabs collapse/expand reducer tests

* test(tabs): assert expand reducers preserve the other pane's collapse flag
2026-06-09 20:03:48 +05:30
sharan-bruno
216d8e7151 fix(ui): prevent empty header row from persisting state and crashing CLI (#8167)
* fix: 3228 Empty header row persists in state, file, and crashes CLI

* fix: refactor test steps for auto-append empty header row functionality

* fix: update key column identification to use isKeyField property

* fix: prevent duplicate empty rows in EditableTable and improve empty row detection

* fix: update addMultipartFileToLastRow to target the last row correctly

* addressed review comment
2026-06-09 18:51:24 +05:30
sharan-bruno
13a48a256f fix(cli): use path name for classname in JUnit reports instead of request URL (#8169)
* fix: 3123 CLI JUnit Report: classname Uses Request URL Instead of Request Name

* fix: update classname in JUnit report to use request path instead of name

* fix: update testcase classname in JUnit report to use request path instead of request name

* fix: update JUnit report classname to use API paths instead of collection paths

* fix: update classname in JUnit report to use backslashes for Windows compatibility

* fix: update JUnit report file paths to use API paths instead of mock paths
2026-06-09 15:58:18 +05:30
sharan-bruno
240826ebc1 fix(grpc): gRPC request loses all messages except the first on save for yaml collection (#8203)
* fix(grpc): gRPC request loses all messages except the first on save for yaml collection

* fix(grpc): enhance gRPC locators and improve message handling in tests
2026-06-09 14:55:04 +05:30
shubh-bruno
6f47218a81 fix(generate-code)!: generate code URL issues (#8136) 2026-06-09 12:54:24 +05:30
sharan-bruno
95c75c90c1 fix(tests): update timeline item locators and improve response status code assertion (#8202) 2026-06-09 10:04:32 +05:30
sharan-bruno
366d85b141 fix(tests): update locators for save button in presets indicator tests (#8201)
docs: add gRPC request flow documentation
docs: add HTTP request execution flow documentation
2026-06-09 09:53:11 +05:30
Chirag Chandrashekhar
b9ee1ee523 test(core): current mount pipeline (#7466) 2026-06-08 20:57:33 +05:30
sharan-bruno
2d4d4e4037 fix(ui): correct “modified” indicator state across collection, folder, request, and presets/auth tabs (#3386) (#8027)
* fix: 3296 Folder-level No Auth inheritance is ignored; requests still use Collection Auth
2026-06-08 16:57:18 +05:30
Pooja
b9d8bdf2ec feat(ws): multiple messages support in websockets (#8115)
* feat: ws multi message

* fix

* fix

* fix

* improve: UX

* improve: new message ui

* fix

* fix

* fix

* fix

* fix

* fix: rename message title

* chore: cleanup

* change: add message color

* fix(websocket): correct cursor and truncate long message names

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2026-06-08 16:03:43 +05:30
Abhishek S Lal
913214e96b docs: update README to include Bruno CLI and Docker usage instructions (#8184) 2026-06-05 18:50:48 +05:30
rajashreehj-bruno
f629c3dd20 fix/3112 - Postman import: OAuth2 Implicit Grant Type Silently Converted to Client Credentials on Import (#8113)
* fix/3112 - Postman import: OAuth2 Implicit Grant Type Silently Converted to Client Credentials on Import

* fix/3112: Postman import: OAuth2 Implicit Grant Type Silently Converted to Client Credentials on Import

* fix/3112 - Postman import: OAuth2 Implicit Grant Type Silently Converted to Client Credentials on Import

* fix/3112

* Implicit grant type

* Oauth2 implicit grant type test case
2026-06-05 17:31:17 +05:30
Pooja
b70bfb26d4 rm: deps array (#8181) 2026-06-04 17:47:12 +05:30
Sundram
a8b938fe4c fix(bruno-app): use primary accent in OpenAPI Sync settings modal (#8161)
* fix(bruno-app): use primary accent in OpenAPI Sync settings modal

Active state for the Auto-check for updates toggle and the URL/File
mode buttons in the Connection Settings modal now use the same primary
theme accent as the Save button and the active Check interval pill,
matching visual consistency across themes.

Refs: BRU-3409

* fix(bruno-app): refine OpenAPI Sync settings modal accents

Keep the auto-check toggle on the primary accent, but restore the
URL/File source buttons to their neutral active style and make the
check-interval pills use an inline yellow style (accent border + tint
+ accent text) instead of a solid primary fill, matching the Appearance
theme toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:20:13 +05:30
naman-bruno
dadd69b02d feat: AI features into preferences and Redux store (#8178) 2026-06-04 13:27:55 +05:30
Pooja
8f80230708 fix(proxy): refresh cached PAC content on demand (#8173) 2026-06-04 11:59:09 +05:30
prateek-bruno
026dbfb108 fix: openapi spec export crash on websocket request (#8132)
* fix: only accept http and graphql for openapi spec

* chore: add test

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

---------

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>
2026-06-03 16:54:25 +05:30
Abhishek Patil
462a39308d fix(proxy): proxy config export from v2 to import in v3 (#8112)
* FIXED regression for proxy config from v2 to v3

* REMOVED console.log

* ADDED test case with fixture to test proxy import

* ADDED proxy handling for older brunoConfig in  packages/bruno-electron/src/utils/collection-import.js

* RESOLVED githiub converstation

changed afterAll --> afterEach

* ADDED guard to transformProxyConfig(brunoConfig.proxy) function
2026-06-01 19:14:41 +05:30
Pooja
f23e406ef8 feat: show scripted requests in timeline (#8047) 2026-06-01 18:36:32 +05:30
naman-bruno
db91dbf192 feat: npm package report and installation support (#8143) 2026-06-01 13:38:22 +05:30
Pooja
7413465bb4 fix(app): preserve multipart file values when creating example from r… (#8129) 2026-05-29 18:56:27 +05:30
Sundram
18761ee156 Merge pull request #8109 from sundram-bruno/fix/bru-3300-swagger-tryitout-cors
fix(app): make SwaggerUI "Try it out" work cross-origin in API Spec viewer (BRU-3300)
2026-05-29 17:35:12 +05:30
Bijin A B
d8b6701bb5 Merge pull request #8146 from abhishekp-bruno/bugfix/workspace-open-success-toast-on-cancel
Bugfix/workspace open success toast on cancel
2026-05-29 17:27:36 +05:30
abhishekp-bruno
472241b51c fix: return null when workspace selection is cancelled in openWorkspaceDialog 2026-05-29 16:17:53 +05:30
sanish chirayath
244f528277 feat(import): enhance import functionality with issue tracking and logging (#8098)
* feat: enhance import functionality with issue tracking and logging

- Updated the import process to return both collections and issues for better error handling.
- Introduced a new toast notification for displaying import issues, allowing users to copy or report them.
- Enhanced logging for import issues, capturing errors and warnings during the import process.
- Added new components for actionable toasts and import issues display.
- Updated tests to validate the new import behavior and issue tracking.

* feat: enhance import issues handling with new toast notifications and tests

- Added optional testId prop to ActionableToast for better test targeting.
- Updated ImportIssuesToast to include data-testid attributes for improved e2e testing.
- Introduced a new Postman collection fixture to test partial import scenarios.
- Created new tests to validate the import process, including issue reporting and copying functionality.
- Implemented utility functions to manage import issues toasts during tests.

* fix: improve clipboard copy functionality and handle import issues more robustly

- Updated BulkImportCollectionLocation to always set import issues, ensuring consistent state management.
- Enhanced clipboard copy functionality in ImportIssuesToast and BulkImportCollectionLocation to handle errors gracefully with user feedback.
- Added aria-label for better accessibility in ActionableToast close button.

* refactor: enhance import issue logging and toast notifications

- Improved logging in BulkImportCollectionLocation and ImportCollectionLocation to provide detailed summaries of import issues, including counts of skipped items and warnings.
- Updated ImportIssuesToast to handle long issue descriptions and provide user feedback for copying issue details to the clipboard.
- Removed ActionableToast component and its styles, consolidating toast functionality within ImportIssuesToast for better maintainability.
- Enhanced styling for ImportIssuesToast to improve user experience and accessibility.

* refactor: update logging level for import issues in BulkImportCollectionLocation and ImportCollectionLocation

- Changed log type from 'error' to 'warn' for import issue summaries in both components to better reflect the severity of the messages.
- This adjustment improves clarity in the logging system and aligns with the intended handling of import warnings.

* feat: enhance ImportIssuesToast with URL length warning and styling improvements

- Added an alert icon and improved styling for the URL-too-long warning in ImportIssuesToast to enhance user experience.
- Introduced a new test for verifying the display of the URL length warning when importing collections with many issues.
- Updated locators to include a test ID for the URL-too-long warning, facilitating better end-to-end testing.

* style: update ImportIssuesToast styling for improved user experience

- Changed background and border colors in StyledWrapper for better visual consistency.
- Enhanced box-shadow and close button styles for improved accessibility and interaction.
- Adjusted padding and gap in warning messages for better layout and readability.
2026-05-28 19:41:03 +05:30
sharan-bruno
49088e98c8 fix/902 --bail flag not stopping execution when a test fails (#8103)
* fix/902 --bail flag not stopping execution when a test fails in a CSV file

* addressed review comments

* addressed review comments

* updated the package-lock file

* addressed review comments

* addressed review comments

* fix: add stripExtension utility to suitename assignment in run command
2026-05-28 16:39:25 +05:30
prateek-bruno
b43a5e6e0a feat: import modal revamp (#8121) 2026-05-28 15:58:22 +05:30
Sundram
4ee9a75465 fix(import): preserve special chars in OpenAPI tag/folder names for yml collections (BRU-3175) (#8123)
The OpenAPI importer's tag-sanitization step rewrote every non-alphanumeric
character to `_` unconditionally, regardless of target collection format.
That's correct for `.bru` (whose grammar restricts list items to
`(alnum | "_" | "-")+`) but wrong for the opencollection (yml) target,
whose Tag schema imposes no character restriction. As a result:

  `Pets & Dogs`  →  `Pets_Dogs`
  `R&D`          →  `R_D`
  `&`            →  dropped

This fix makes `sanitizeTag` branch on `options.collectionFormat`:
- `yml` → trim only, preserve verbatim
- `bru` (or default) → keep existing BRU-grammar sanitization

Three call sites updated:

1. `packages/bruno-converters/src/common/index.js` — `sanitizeTag`
   honors `options.collectionFormat`.
2. `packages/bruno-converters/src/openapi/openapi-common.js` —
   `groupRequestsByTags` now accepts + threads `options` so the
   folder-grouping path also respects format.
3. `packages/bruno-schema/src/collections/index.js` — `itemSchema.tags`
   regex relaxed to `Yup.string().min(1)` to match the OpenCollection
   `Tag = string` spec; old regex enforced BRU grammar on the in-memory
   collection shape and rejected our newly-preserved tags downstream.

Cross-platform safety: tags carrying FS-dangerous characters (`/`, `\`,
control chars, Windows-forbidden chars, trailing dot/space) are still
made safe on disk by Bruno's existing `sanitizeName` (in
`packages/bruno-electron/src/utils/filesystem.js`). UI sidebar reads
`info.name` from `folder.yml`, so user-facing label preserves the
verbatim tag while the on-disk path stays portable. Behavior verified
identical on macOS / Linux / Windows for the AC examples + common
inputs. Windows-reserved tag names (`CON`, `PRN`, etc.) and
filesystem-inherent issues (case-sensitivity, length limits) are
pre-existing gaps in Bruno's writer, not in scope here.

Tests:
- `tests/common/sanitizeTag.spec.js` — replaced the old "always sanitize"
  test (which locked in the buggy behavior) with a `collectionFormat`
  branch covering yml-preservation + bru-strict for the ticket's 3
  examples plus dot/parens/whitespace edge cases.
- `tests/openapi/openapi-to-bruno/openapi-tags.spec.js` — added a
  `describe('yml tag preservation')` block exercising the full importer
  pipeline (request tags + folder grouping) on the 3 AC examples.
- `bruno-schema/src/collections/itemSchema.spec.js` — updated the
  validation test to reflect the relaxed schema; verified that previously
  rejected strings (`Pets & Dogs`, `R&D`, `&`, emoji, etc.) now pass and
  empty strings still fail.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 15:03:54 +05:30
Pooja
413697cbe7 fix: honor OS-level PAC configuration in system proxy mode (#7766) 2026-05-27 14:04:00 +05:30
Sundram
6b7e5f3813 fix(app): null-safe OAuth2 scope in OpenAPI export (BRU-3297) (#8086)
* fix(app): null-safe OAuth2 scope in OpenAPI export (BRU-3297)

The OpenAPI exporter calls `.length` on `auth.oauth2.scope` without a
null guard. When a user never fills the Scope field for an OAuth2 auth
(grant types authorization_code, password, client_credentials), Bruno
stores `scope` as `null`, causing the entire export to crash with
`TypeError: Cannot read properties of null (reading 'length')`.

Replace the unsafe `scope.length > 0` check with a truthy check that
handles null, undefined, and empty string uniformly. Emit `scopes` as
an empty object when no scope is set — OpenAPI 3.0 requires `scopes`
to be present on every OAuth2 flow even when empty.

Add 12 jest tests covering all 3 affected grant types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style: wrap oauth2 case in block to scope local declarations

Addresses Biome `noSwitchDeclarations` — `const` declarations inside a
case clause without braces can leak across cases. Pure cosmetic; no
behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: drop internal scope-discussion comment in openapi-spec test

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:06:38 +05:30
prateek-bruno
d9c13e74ac feat: add support for duplicate request url + type in OpenAPI spec (#8028) 2026-05-26 20:32:56 +05:30
Sid
809f951a47 fix: tab type resolution for non request types (#8097)
* fix: tab type resolution for non request types

* Remove console log from snapshot test

Removed console log statement from folder.spec.ts

* test(snapshot): deserializeTab test addition for the removed guard
2026-05-26 13:03:57 +05:30
shubh-bruno
2e0094fc46 fix: varinfo drag-select dismiss (#8070) 2026-05-26 10:51:33 +05:30
DeviSriSaiCharan
39308bc03c fix: prevent success toast when workspace selection is cancelled 2026-05-25 20:57:11 +05:30
Sid
a3e3199490 fix: multipart/mixed and multipart/form-data interpolation and generic request behaviour (#8087)
* fix: multipart spec additions and interpolation fixes

* test(cli): add interpolation multipart  tests

* Update packages/bruno-tests/collection/multipart/multipart-mixed-form-data-parse.bru

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

* chore: remove assert as curl also gives the same result

* chore: codestyle

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-25 20:05:50 +05:30
Sid
87d97ba0ef Revert "perf: optimize DNS resolution to reduce request latency (#7550)" (#7723)
This reverts commit 55b952f958601b3177607a067d74352e6a4a6cd4.
2026-05-25 17:07:36 +05:30
ganesh
b20893eee1 fix: semicolon (#8088) 2026-05-25 16:18:18 +05:30
sanish chirayath
9b0911926c fix: use OS resolver for .local hostnames to fix mDNS resolution (#8072) 2026-05-22 17:36:58 +05:30
Pooja
8cd7c26648 feat: multi-file upload in multipart form body (#7971) 2026-05-21 20:20:25 +05:30
sharan-bruno
611724a744 Fix/pm.set next request(null) not translating (#8062)
* fix: 3093 - Fix pm.setNextRequest(null) not translating to bru.runner.stopExecution()

* addressed review comments

* addressed review comments

* Behavioral change for null case and added transilation for pm.exicution.setNextRequest("req")

* addressed review comments
2026-05-21 18:09:22 +05:30
Sundram
71b53ee0bc docs(cli): polish Docker Hub README rendering — absolute links, blockquote restructure, streamline steps (#8064)
* docs(cli): fix Docker Hub overview — absolute README links + restructure Step 3 blockquotes

* docs(cli): pull --rm example out of blockquote so Docker Hub renders it cleanly

* docs(cli): drop folder-structure block from Docker Hub overview
2026-05-21 18:06:47 +05:30
sanish chirayath
113e28dc3c feat: add bru.hasGlobalEnvVar method and update translations (#8037)
* feat: add bru.hasGlobalEnvVar method and update translations

- Introduced the `bru.hasGlobalEnvVar(key)` method to check for the existence of global environment variables.
- Updated translation mappings in Postman converters to include `pm.globals.has` for `bru.hasGlobalEnvVar`.
- Enhanced test cases to validate the new method and its translation in both directions between Bruno and Postman.

* feat: add hasGlobalEnvVar method to bru shim

- Implemented the `hasGlobalEnvVar` method in the bru shim to check for the existence of global environment variables.
- Updated the context setup to include the new method, enhancing the functionality of the environment variable management.
2026-05-21 00:51:51 +05:30
Sid
2d25b2cfb0 chore: add stack trace to boundary (#8040) 2026-05-21 00:49:59 +05:30
Sundram
f916b19a6f feat(cli): add Docker Compose example for the Bruno CLI Docker image (#8036)
* feat(cli): add Docker Compose example for the Bruno CLI Docker image

* Update packages/bruno-cli/docker/README.md

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

* refactor(docker-compose): move docker-compose.yml outside the collection folder

Keeps packages/bruno-tests/collection/ as pure Bruno collection content
(bruno.json, .bru files, environments). The docker-compose example now
sits one level up and mounts ./collection into the container, so the
collection stays portable.

* docs(cli): omit --rm from docker examples, add note explaining when to use it

Command examples in docker/README.md no longer suggest --rm by default so
users can docker logs / docker inspect the stopped container after a run.
A note panel under Step 3 explains what --rm does and when to opt in (CI
hygiene, avoiding stopped-container buildup). The version-check command
in Step 2 keeps --rm since it is a one-shot sanity probe. Alpine and
Debian sub-READMEs follow the same policy; the explanatory note lives
only in the main docker/README.md.

* feat(bruno-tests): wire docker-compose to emit JSON, JUnit, HTML reports via mounted reports/ dir

* docs(cli): apply PR review feedback — rephrase step 3 intro, use latest image tag, use placeholder collection path

* docs(cli): apply EM review — trim bru-only steps, generalize options note, dedupe tag table, consolidate gitignore

* docs(cli): minor README polish in docker docs (add --rm to CI example, simplify collection path placeholder)

* docs(cli): drop --env staging from generic examples, pin CI snippets to :latest, reposition --rm note

* docs: updated Readme.md

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-20 21:13:27 +05:30
Kanhaiya Pandey
c5528a75a6 feat: add default http protocol when URL scheme is missing (#7786)
---------

Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-05-20 20:43:47 +05:30
sharan-bruno
4b214693c4 fix: 3093 - Fix pm.setNextRequest(null) not translating to bru.runner.stopExecution() (#8049)
* fix: 3093 - Fix pm.setNextRequest(null) not translating to bru.runner.stopExecution()

* addressed review comments

* addressed review comments
2026-05-20 19:22:55 +05:30
naman-bruno
023630338b fix: select overview tab when closing all tabs (#8026) 2026-05-20 16:32:57 +05:30
prateek-bruno
e0de7d5557 fix: relative path getting stored as absolute on windows (#7895) 2026-05-19 23:53:37 +05:30
Sid
e86a036fd6 fix: allow users to clear the cache, adds environment tests and re-serialization addition (#8035)
* internal emit chain for clearance

* fix: crash ui

* fix(ErrorBoundary): ensure cache clearing is awaited before force quitting

* test(e2e): environment persistence across collections

* test: migration test

* Update environment.spec.ts

* feat: add missing status assertions and .not negation support (#7660)

* feat: add new status assertions and negated variants for response checks

- Introduced new status assertions: `pm.response.to.be.info`, `pm.response.to.be.accepted`, `pm.response.to.be.badRequest`, `pm.response.to.be.unauthorized`, `pm.response.to.be.forbidden`, `pm.response.to.be.notFound`, `pm.response.to.be.rateLimited`, and `pm.response.to.be.withoutBody`.
- Implemented negated variants for existing assertions, allowing checks like `pm.response.to.not.be.ok` and `pm.response.to.not.be.success`.
- Enhanced test coverage to validate the translation of these new assertions and their negated forms in the Postman to Bruno conversion process.

* refactor: update response translation for body assertions

- Changed the translation logic for `pm.response.to.be.withoutBody` to `pm.response.to.be.withBody`, reflecting the actual body content checks.
- Updated corresponding test cases to validate the new assertions and ensure correct translation behavior for body presence checks.
- Enhanced negated variants for body assertions to align with the updated logic.

* feat: add header transformation methods for Postman to Bruno conversion

- Introduced new transformations for `pm.request.headers.prepend`, `pm.request.headers.insert`, and `pm.request.headers.insertAfter` to map to `req.headerList.add`, enhancing header management during the conversion process.
- Updated the transformation logic to ensure only the first argument is retained for these methods, aligning with the intended behavior of the header list operations.

* refactor: update header transformation logic for Postman to Bruno conversion

- Simplified the transformation for `pm.response.to.have.header` and `pm.response.to.not.have.header` to use `res.getHeader` instead of `res.getHeaders`, improving clarity and consistency in the assertions.
- Adjusted related test cases to validate the new transformation logic, ensuring accurate translation of header checks in the conversion process.

* refactor: update header transformation logic for Postman to Bruno conversion

- Enhanced the transformation for `pm.response.to.have.header` and `pm.response.to.not.have.header` to utilize `res.getHeaders()` with lowercased header names, improving consistency and accuracy in header assertions.
- Updated related test cases to reflect the new transformation logic, ensuring correct translation of header checks in the conversion process.

* feat: add data-driven status assertions for response checks

- Introduced a new utility to generate data-driven status assertion entries for `pm.response.to.be.*` checks, including positive and negated variants.
- Integrated the new status assertions into the Postman to Bruno translation logic, enhancing the capability to handle various response status checks.
- Updated tests to validate the translation of new assertions, ensuring accurate conversion of status checks in the response handling process.

* feat: enhance response assertion translations for negated variants

- Updated the transformation logic for `pm.response.to.have.*` assertions to include negated variants, allowing for patterns like `pm.response.to.have.not.status`, `pm.response.to.have.not.header`, and `pm.response.to.have.not.body`.
- Adjusted related test cases to validate the new translations, ensuring accurate conversion of negated assertions in the response handling process.

* refactor: convert status assertion utility to ES module syntax

- Changed the export of `buildStatusAssertionEntries` to ES module syntax for better compatibility with modern JavaScript practices.
- Updated the import statement in the Postman to Bruno translator to reflect the new export format, ensuring seamless integration of the status assertion utility.

* feat: update response body assertion translations to use undefined checks

- Modified the transformation logic for `pm.response.to.be.withBody`, `pm.response.to.not.be.withBody`, and `pm.response.to.be.not.withBody` to use an undefined check instead of truthiness, allowing for accurate handling of falsy body values.
- Updated related test cases to reflect these changes, ensuring correct translation of body presence assertions in the response handling process.

* feat: support newer Postman export format with collection envelope (#8038)

- Updated the Postman collection importer to handle collections wrapped in a { collection: { ... } } format.
- Enhanced the parsing logic to extract collection info correctly from both legacy and newer formats.
- Added a new test case for importing a Postman v2.1 collection with the wrapped format to ensure compatibility.

* fix: reduce padding for dark mode app errors

---------

Co-authored-by: sanish chirayath <sanish@usebruno.com>
2026-05-19 22:53:36 +05:30
sanish chirayath
454b43942c feat: support newer Postman export format with collection envelope (#8038)
- Updated the Postman collection importer to handle collections wrapped in a { collection: { ... } } format.
- Enhanced the parsing logic to extract collection info correctly from both legacy and newer formats.
- Added a new test case for importing a Postman v2.1 collection with the wrapped format to ensure compatibility.
2026-05-19 19:57:44 +05:30
sanish chirayath
8cc3a670c6 feat: add missing status assertions and .not negation support (#7660)
* feat: add new status assertions and negated variants for response checks

- Introduced new status assertions: `pm.response.to.be.info`, `pm.response.to.be.accepted`, `pm.response.to.be.badRequest`, `pm.response.to.be.unauthorized`, `pm.response.to.be.forbidden`, `pm.response.to.be.notFound`, `pm.response.to.be.rateLimited`, and `pm.response.to.be.withoutBody`.
- Implemented negated variants for existing assertions, allowing checks like `pm.response.to.not.be.ok` and `pm.response.to.not.be.success`.
- Enhanced test coverage to validate the translation of these new assertions and their negated forms in the Postman to Bruno conversion process.

* refactor: update response translation for body assertions

- Changed the translation logic for `pm.response.to.be.withoutBody` to `pm.response.to.be.withBody`, reflecting the actual body content checks.
- Updated corresponding test cases to validate the new assertions and ensure correct translation behavior for body presence checks.
- Enhanced negated variants for body assertions to align with the updated logic.

* feat: add header transformation methods for Postman to Bruno conversion

- Introduced new transformations for `pm.request.headers.prepend`, `pm.request.headers.insert`, and `pm.request.headers.insertAfter` to map to `req.headerList.add`, enhancing header management during the conversion process.
- Updated the transformation logic to ensure only the first argument is retained for these methods, aligning with the intended behavior of the header list operations.

* refactor: update header transformation logic for Postman to Bruno conversion

- Simplified the transformation for `pm.response.to.have.header` and `pm.response.to.not.have.header` to use `res.getHeader` instead of `res.getHeaders`, improving clarity and consistency in the assertions.
- Adjusted related test cases to validate the new transformation logic, ensuring accurate translation of header checks in the conversion process.

* refactor: update header transformation logic for Postman to Bruno conversion

- Enhanced the transformation for `pm.response.to.have.header` and `pm.response.to.not.have.header` to utilize `res.getHeaders()` with lowercased header names, improving consistency and accuracy in header assertions.
- Updated related test cases to reflect the new transformation logic, ensuring correct translation of header checks in the conversion process.

* feat: add data-driven status assertions for response checks

- Introduced a new utility to generate data-driven status assertion entries for `pm.response.to.be.*` checks, including positive and negated variants.
- Integrated the new status assertions into the Postman to Bruno translation logic, enhancing the capability to handle various response status checks.
- Updated tests to validate the translation of new assertions, ensuring accurate conversion of status checks in the response handling process.

* feat: enhance response assertion translations for negated variants

- Updated the transformation logic for `pm.response.to.have.*` assertions to include negated variants, allowing for patterns like `pm.response.to.have.not.status`, `pm.response.to.have.not.header`, and `pm.response.to.have.not.body`.
- Adjusted related test cases to validate the new translations, ensuring accurate conversion of negated assertions in the response handling process.

* refactor: convert status assertion utility to ES module syntax

- Changed the export of `buildStatusAssertionEntries` to ES module syntax for better compatibility with modern JavaScript practices.
- Updated the import statement in the Postman to Bruno translator to reflect the new export format, ensuring seamless integration of the status assertion utility.

* feat: update response body assertion translations to use undefined checks

- Modified the transformation logic for `pm.response.to.be.withBody`, `pm.response.to.not.be.withBody`, and `pm.response.to.be.not.withBody` to use an undefined check instead of truthiness, allowing for accurate handling of falsy body values.
- Updated related test cases to reflect these changes, ensuring correct translation of body presence assertions in the response handling process.
2026-05-19 18:04:51 +05:30
Sid
cea883eda2 fix(snapshot): normalize renderer:get-last-opened-workspaces output to avoid reactivating a deleted workspace (#8033)
* fix: normalize workspace paths during workspace switch to prevent stale state

* chore: test text

* tests(snapshot): more workspace coverage
2026-05-18 21:24:58 +05:30
Bijin A B
e8468ac9a5 Merge pull request #7939 from usebruno/fix/7642-stream-files-internal
fix: preserve stream-backed file bodies during request interpolation …
2026-05-18 14:36:20 +05:30
gopu-bruno
10da27dde8 fix: workspace home icon alignment in title bar when already fullscreen (#7967) 2026-05-18 12:54:07 +05:30
Pooja
55774a8258 fix: restore saved credentials when switching back to original auth mode (#7911) 2026-05-18 12:52:10 +05:30
Chirag Chandrashekhar
736c050dae feat: add benchmark framework for collection mount performance (#7915) 2026-05-18 12:19:23 +05:30
mehmet turac
b79349b052 fix: normalize select file labels (#8021) 2026-05-18 05:07:54 +05:30
Sid
da779883a6 chore: change loader (#8009) 2026-05-15 17:45:35 +05:30
Sid
48c88df3a8 fix: handle transient requests during app quit flow in SaveRequestsModal (#8003)
* fix: handle transient requests during app quit flow in SaveRequestsModal

* test: non serial

* chore: fix theme

* fix: ui polish

* chore: import

* chore: cr
2026-05-15 16:27:56 +05:30
Sundram
bdc5d1e017 chore(cli): point Docker maintainer label to support@usebruno.com (#8007) 2026-05-15 14:46:52 +05:30
prateek-bruno
a2ec2c6d56 fix: add resize listener for code mirror instances (#7889) 2026-05-15 14:40:19 +05:30
prateek-bruno
df06d1558b feature: open in terminal from manage workspace (#7877) 2026-05-15 14:29:59 +05:30
Sid
bd2b1ecb70 chore: fix eslint 2026-05-15 13:55:47 +05:30
shubh-bruno
1ab2368f0f fix: file streaming for multipart bodies (#7998) 2026-05-15 13:47:36 +05:30
gopu-bruno
351b294c3f fix: show "+ Add request" CTA in empty .bru collection sidebar (#8000)
* fix: show empty collection Add request CTA when only files exist

* test: add .bru parity to empty-state CTA spec
2026-05-14 21:46:33 +05:30
Sundram
c282a955f8 feat(cli): add Docker image with alpine and debian variants (#8001) 2026-05-14 18:45:17 +05:30
shubh-bruno
612b99460b fix: save dotenv cmd s (#8002) 2026-05-14 18:31:32 +05:30
Bijin A B
d79aabb9f5 tests: playwright tests for all OS environments 2026-05-14 17:38:55 +05:30
shubh-bruno
9190de53ad fix/send request shortcut issue (#7993) 2026-05-14 13:14:54 +05:30
Sid
31dedc3c95 feat: Stabilize variable info editor transition and popup lifecycle (#7912)
* feat: Stabilize variable info editor transition and popup lifecycle (#7458) (#7818)

* fix: prevent var-info split second UI flicker.

Co-authored-by: Mhammed Reda El Jirari <ridaeljirari@gmail.com>
Co-authored-by: Fred Rukundo <dukefred9@gmail.com>
Co-authored-by: El Mehdi Bennamrouche <bennamrouchex@gmail.com>
Co-authored-by: Ahmed Bouregba <medex0606@gmail.com>

* fix: variable-tooltip hover issues (#7907)

---------

Co-authored-by: Avictos <125824783+avictos@users.noreply.github.com>
Co-authored-by: Mhammed Reda El Jirari <ridaeljirari@gmail.com>
Co-authored-by: Fred Rukundo <dukefred9@gmail.com>
Co-authored-by: El Mehdi Bennamrouche <bennamrouchex@gmail.com>
Co-authored-by: Ahmed Bouregba <medex0606@gmail.com>
Co-authored-by: shubh-bruno <shubham@usebruno.com>
2026-05-13 21:49:14 +05:30
Sid
57d2fc7899 fix: example-request tab collision (#7989)
* fix: prevent response-example tabs from hijacking request sidebar selection

* fix: add selectors for examples with index

* test: better locator

* fix: duplicate name collision

* fix: refactor sidebar example handling functions for better clarity and reusability

* chore: cr comments
2026-05-13 21:47:39 +05:30
sharan-bruno
4fa882c67c fix: 3014 - Postman import ignores API Key "in" placement setting and defaults to header (#7968)
* fix: 3014 - Postman import ignores API Key "in" placement setting and defaults to header

* addressed review comments

* addressed review comment

* addressed review comment - added a test id

* addressed reveiew comment
2026-05-13 19:16:44 +05:30
Sid
2c9dc9dcf8 fix: correct the request type tabs in the snapshot (#7994) 2026-05-13 19:05:34 +05:30
Sid
9df06e152a fix: add tab error boundary (#7987) 2026-05-13 18:48:57 +05:30
prateek-bruno
9f75959452 handle empty mime type in insomnia importer (#7828) 2026-05-13 15:58:02 +05:30
prateek-bruno
dd922c7163 fix: show whitespace-only error on Create workspace and collection modal (#7883) 2026-05-13 09:59:42 +05:30
Sid
d8fb7c7e7e ai: coding standards for e2e tests (#7973)
* ai: coding standards for e2e tests

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 17:32:27 +05:30
phoval
4ad51186a1 fix(bruno-electron): interpolate auth headers for GraphQL introspection request (#5560) 2026-05-11 11:13:21 +05:30
Hritam Shrivastava
975c638f39 fix: preserve stream-backed file bodies during request interpolation (#7690) 2026-05-07 14:05:14 +05:30
578 changed files with 32396 additions and 4350 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Force LF line endings for all text files
* text=auto eol=lf

View File

@@ -5,6 +5,10 @@ inputs:
description: 'Skip building libraries'
required: false
default: 'false'
shell:
description: 'Shell to use (bash, pwsh)'
required: false
default: 'bash'
runs:
using: 'composite'
steps:
@@ -16,12 +20,12 @@ runs:
cache-dependency-path: './package-lock.json'
- name: Install node dependencies
shell: bash
shell: ${{ inputs.shell }}
run: npm ci --legacy-peer-deps
- name: Build libraries
if: inputs.skip-build != 'true'
shell: bash
shell: ${{ inputs.shell }}
run: |
npm run build:graphql-docs
npm run build:bruno-query

View File

@@ -7,13 +7,13 @@ runs:
shell: bash
run: |
set -euo pipefail
xvfb-run npm run test:e2e:ssl
- name: Upload Playwright Report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-linux
name: playwright-report-linux-ssl
path: playwright-report/
retention-days: 30

View File

@@ -12,6 +12,6 @@ runs:
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-macos
name: playwright-report-macos-ssl
path: playwright-report/
retention-days: 30

View File

@@ -12,6 +12,6 @@ runs:
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report-windows
name: playwright-report-windows-ssl
path: playwright-report/
retention-days: 30

View File

@@ -0,0 +1,38 @@
name: 'Run Benchmark Tests'
description: 'Run Playwright benchmark tests and compare against baseline'
inputs:
os:
description: 'Operating system (ubuntu, macos, windows)'
default: 'ubuntu'
update-baseline:
description: 'Update baseline instead of comparing'
default: 'false'
runs:
using: 'composite'
steps:
- name: Run Benchmark Tests (Ubuntu)
if: inputs.os == 'ubuntu'
shell: bash
run: xvfb-run npm run test:benchmark
- name: Run Benchmark Tests
if: inputs.os != 'ubuntu'
shell: bash
run: npm run test:benchmark
- name: Update Baseline
if: inputs.update-baseline == 'true'
shell: bash
run: >-
node tests/benchmarks/utils/compare.js
--results tests/benchmarks/results/mounting.json
--baseline tests/benchmarks/mounting/baseline.${{ inputs.os }}.json
--update-baseline
- name: Compare Against Baseline
if: inputs.update-baseline != 'true'
shell: bash
run: >-
node tests/benchmarks/utils/compare.js
--results tests/benchmarks/results/mounting.json
--baseline tests/benchmarks/mounting/baseline.${{ inputs.os }}.json

View File

@@ -1,20 +1,41 @@
name: 'Run CLI Tests'
description: 'Setup dependencies, start local testbench and run CLI tests'
inputs:
shell:
description: 'Shell to use (bash, pwsh)'
required: false
default: 'bash'
runs:
using: 'composite'
steps:
- name: Run Local Testbench
shell: bash
- name: Install Test Collection Dependencies
shell: ${{ inputs.shell }}
run: npm ci --prefix packages/bruno-tests/collection
- name: Run Local Testbench and CLI Tests
if: inputs.shell != 'pwsh'
shell: ${{ inputs.shell }}
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Install Test Collection Dependencies
shell: bash
run: npm ci --prefix packages/bruno-tests/collection
- name: Run CLI Tests
shell: bash
run: |
cd packages/bruno-tests/collection
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
- name: Run Local Testbench and CLI Tests - Windows
if: inputs.shell == 'pwsh'
shell: pwsh
run: |
$process = Start-Process "npm.cmd" `
-ArgumentList "start","--workspace=packages/bruno-tests" `
-NoNewWindow `
-PassThru
Start-Sleep -Seconds 5
if ($process.HasExited) {
Write-Error "Server exited early"
exit 1
}
cd packages/bruno-tests/collection
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer

View File

@@ -4,19 +4,23 @@ inputs:
os:
description: 'Operating system (ubuntu, macos, windows)'
default: 'ubuntu'
shell:
description: 'Shell to use (bash, pwsh)'
required: false
default: 'bash'
runs:
using: 'composite'
steps:
- name: Install Test Collection Dependencies
shell: bash
shell: ${{ inputs.shell }}
run: npm ci --prefix packages/bruno-tests/collection
- name: Run Playwright Tests (Ubuntu)
if: inputs.os == 'ubuntu'
shell: bash
run: xvfb-run npm run test:e2e
run: xvfb-run dbus-run-session -- npm run test:e2e
- name: Run Playwright Tests
if: inputs.os != 'ubuntu'
shell: bash
shell: ${{ inputs.shell }}
run: npm run test:e2e

View File

@@ -1,48 +1,53 @@
name: 'Run Unit Tests'
description: 'Setup dependencies and run unit tests for all packages'
inputs:
shell:
description: 'Shell to use (bash, pwsh)'
required: false
default: 'bash'
runs:
using: 'composite'
steps:
- name: Test Package bruno-js
shell: bash
run: npm run test --workspace=packages/bruno-js
shell: ${{ inputs.shell }}
run: npm run test:ci --workspace=packages/bruno-js
- name: Test Package bruno-cli
shell: bash
run: npm run test --workspace=packages/bruno-cli
shell: ${{ inputs.shell }}
run: npm run test:ci --workspace=packages/bruno-cli
- name: Test Package bruno-query
shell: bash
shell: ${{ inputs.shell }}
run: npm run test --workspace=packages/bruno-query
- name: Test Package bruno-lang
shell: bash
shell: ${{ inputs.shell }}
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema
shell: bash
shell: ${{ inputs.shell }}
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
shell: bash
shell: ${{ inputs.shell }}
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-common
shell: bash
shell: ${{ inputs.shell }}
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-converters
shell: bash
run: npm run test --workspace=packages/bruno-converters
shell: ${{ inputs.shell }}
run: npm run test:ci --workspace=packages/bruno-converters
- name: Test Package bruno-electron
shell: bash
run: npm run test --workspace=packages/bruno-electron
shell: ${{ inputs.shell }}
run: npm run test:ci --workspace=packages/bruno-electron
- name: Test Package bruno-requests
shell: bash
shell: ${{ inputs.shell }}
run: npm run test --workspace=packages/bruno-requests
- name: Test Package bruno-filestore
shell: bash
shell: ${{ inputs.shell }}
run: npm run test --workspace=packages/bruno-filestore

View File

@@ -1,79 +0,0 @@
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

88
.github/workflows/benchmarks.yml vendored Normal file
View File

@@ -0,0 +1,88 @@
name: Benchmarks
on:
workflow_dispatch:
inputs:
update-baseline:
description: 'Update baseline with current results instead of comparing'
type: boolean
default: false
pull_request:
branches: [main, 'release/v*']
jobs:
benchmark:
name: Performance Benchmarks (${{ matrix.os }})
timeout-minutes: 60
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-latest, windows-latest]
include:
- os: ubuntu-24.04
os-name: ubuntu
- os: macos-latest
os-name: macos
- os: windows-latest
os-name: windows
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v6
- name: Install System Dependencies (Ubuntu)
if: matrix.os-name == 'ubuntu'
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
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Configure Chrome Sandbox
if: matrix.os-name == 'ubuntu'
run: |
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run Benchmark Tests
uses: ./.github/actions/tests/run-benchmark-tests
with:
os: ${{ matrix.os-name }}
update-baseline: ${{ github.event.inputs.update-baseline || 'false' }}
- name: Upload Benchmark Results
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: benchmark-results-${{ matrix.os-name }}
path: |
tests/benchmarks/results/
benchmark-report/
retention-days: 30
- name: Commit Updated Baseline
if: github.event.inputs.update-baseline == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add tests/benchmarks/mounting/baseline.${{ matrix.os-name }}.json
git diff --staged --quiet || git commit -m "chore: update ${{ matrix.os-name }} benchmark baseline" && git push
- name: Comment Benchmark Results on PR
if: github.event_name == 'pull_request' && !cancelled()
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
const run = require('./tests/benchmarks/utils/pr-comment.js');
await run({
github,
context,
resultsPath: 'tests/benchmarks/results/mounting.json',
baselinePath: 'tests/benchmarks/mounting/baseline.${{ matrix.os-name }}.json',
title: 'Benchmark Results — Collection Mount (${{ matrix.os-name }})'
});

View File

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

View File

@@ -1,4 +1,4 @@
name: Tests
name: Linux Tests
on:
workflow_dispatch:
push:
@@ -8,7 +8,7 @@ on:
jobs:
unit-test:
name: Unit Tests
name: Unit Tests (Linux)
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
@@ -23,7 +23,7 @@ jobs:
uses: ./.github/actions/tests/run-unit-tests
cli-test:
name: CLI Tests
name: CLI Tests (Linux)
runs-on: ubuntu-latest
permissions:
checks: write
@@ -42,13 +42,14 @@ jobs:
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results
check_name: CLI Test Results (Linux)
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
check_run: false
e2e-test:
name: Playwright E2E Tests
timeout-minutes: 60
name: Playwright E2E Tests (Linux)
timeout-minutes: 120
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -58,7 +59,8 @@ jobs:
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
xvfb \
gsettings-desktop-schemas dbus-x11
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
@@ -77,6 +79,61 @@ jobs:
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
name: playwright-report-linux
path: playwright-report/
retention-days: 30
ssl-test:
name: SSL 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/ssl/linux/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/linux/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
oauth1-tests:
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

123
.github/workflows/tests-macos.yml vendored Normal file
View File

@@ -0,0 +1,123 @@
name: macOS Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
jobs:
unit-test:
name: Unit Tests (macOS)
timeout-minutes: 60
runs-on: macos-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Unit Tests
uses: ./.github/actions/tests/run-unit-tests
cli-test:
name: CLI Tests (macOS)
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 CLI Tests
uses: ./.github/actions/tests/run-cli-tests
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action/macos@v2
if: always()
with:
check_name: CLI Test Results (macOS)
files: packages/bruno-tests/collection/junit.xml
comment_mode: off
check_run: false
e2e-test:
name: Playwright E2E Tests (macOS)
timeout-minutes: 150
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run E2E Tests
uses: ./.github/actions/tests/run-e2e-tests
with:
os: macos
- name: Upload Playwright Report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report-macos
path: playwright-report/
retention-days: 30
ssl-test:
name: SSL 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: Setup Feature Dependencies
uses: ./.github/actions/ssl/macos/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/macos/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/macos/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/macos/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/macos/run-ssl-e2e-tests
oauth1-tests:
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

134
.github/workflows/tests-windows.yml vendored Normal file
View File

@@ -0,0 +1,134 @@
name: Windows Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
jobs:
unit-test:
name: Unit Tests (Windows)
if: false # @TODO: Temporarily disabled. Remove this once the tests are fixed.
timeout-minutes: 60
runs-on: windows-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
with:
shell: pwsh
- name: Run Unit Tests
uses: ./.github/actions/tests/run-unit-tests
with:
shell: pwsh
cli-test:
name: CLI Tests (Windows)
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
with:
shell: pwsh
- name: Run CLI Tests
uses: ./.github/actions/tests/run-cli-tests
with:
shell: pwsh
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action/windows@v2
if: always()
with:
check_name: CLI Test Results (Windows)
files: packages/bruno-tests/collection/junit.xml
comment_mode: off
check_run: false
e2e-test:
name: Playwright E2E Tests (Windows)
timeout-minutes: 120
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
with:
shell: pwsh
- name: Run E2E Tests
uses: ./.github/actions/tests/run-e2e-tests
with:
os: windows
shell: pwsh
- name: Upload Playwright Report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report-windows
path: playwright-report/
retention-days: 30
ssl-test:
name: SSL 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
with:
shell: pwsh
- name: Setup CA Certificates
uses: ./.github/actions/ssl/windows/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/windows/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/windows/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/windows/run-ssl-e2e-tests
oauth1-tests:
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

4
.gitignore vendored
View File

@@ -58,6 +58,10 @@ skills-lock.json
# Playwright
/blob-report/
# Benchmark results (generated at runtime)
tests/benchmarks/results/
/benchmark-report/
# Development plan files
CLAUDE.md
AGENTS.md

View File

@@ -59,6 +59,47 @@ Remember, these rules are here to make our codebase harmonious. If something doe
- Tests should be fast enough to run continuously. Avoid long-running operations unless absolutely necessary; prefer lightweight fixtures and isolated units.
### E2E Tests
When reviewing Electron-specific Playwright tests, treat `<project-root>/tests/**` as the canonical location for specs, typically matching `<project-root>/tests/**/*.spec.{ts,js}`. For broader Playwright workflow guidance, also refer to `docs/playwright-testing-guide.md`.
Goal: rewrite or critique the tests so they are genuinely behavioural, maintainable, and safely parallelizable.
Rules:
1. Tests must verify user-visible behaviour, not implementation details.
- Prefer assertions on UI state, persisted data, windows, dialogs, filesystem effects, and app-level outcomes.
- Avoid hardcoded waits, brittle selectors, fake internal state checks, and “click then expect mock called” tests unless the user behaviour is the point.
2. Tests must be Electron-aware.
- Use Electron app launch patterns correctly.
- Handle main window, secondary windows, dialogs, menus, native prompts, clipboard, file pickers, and IPC-driven UI behaviour through observable outcomes.
- Do not reach into app internals unless absolutely necessary for setup or controlled test fixtures.
3. Tests must be parallel-safe.
- No shared user data directories.
- No shared ports, files, DBs, caches, clipboard assumptions, or global app state.
- Each test gets isolated temp paths, unique workspace/project names, and deterministic cleanup.
- Avoid test ordering assumptions.
4. No hardcoded mess.
- Replace magic timeouts with event-driven waits.
- Replace brittle text/index selectors with role, label, test id, or stable user-facing selectors.
- Replace duplicated setup with fixtures.
- Replace hardcoded absolute paths with temp dirs.
- Replace random sleeps with waiting for actual app signals.
5. Every test should follow this shape:
- Arrange: create isolated fixture state.
- Act: perform real user actions.
- Assert: verify observable behavioural outcome.
- Cleanup: remove isolated resources.
For each test file:
- Identify behavioural vs non-behavioural tests.
- Flag brittle selectors, hardcoded waits, shared state, serial dependencies, and fake assertions.
- Rewrite the tests using Playwright best practices for Electron.
- Make them parallel-ready.
- Explain briefly why each rewrite is better.
## UI Specific instructions

688
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,6 +32,7 @@
"@storybook/react-webpack5": "^10.1.10",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
@@ -80,9 +81,10 @@
"watch:common": "npm run watch --workspace=packages/bruno-common",
"watch:requests": "npm run watch --workspace=packages/bruno-requests",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test --project=default",
"test:e2e": "playwright test --project=default --project=system-pac",
"test:e2e:ssl": "playwright test --project=ssl",
"test:e2e:auth": "playwright test --project=auth",
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
"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"
@@ -93,9 +95,9 @@
]
},
"overrides": {
"axios":"1.13.6",
"axios": "1.13.6",
"rollup": "3.30.0",
"pbkdf2":"3.1.5",
"pbkdf2": "3.1.5",
"electron-store": {
"conf": {
"json-schema-typed": "8.0.1"

View File

@@ -124,7 +124,7 @@
"postcss": "8.4.47",
"style-loader": "^3.3.1",
"tailwindcss": "^3.4.1",
"webpack": "^5.64.4",
"webpack": "^5.107.2",
"webpack-cli": "^4.9.1"
},
"overrides": {

View File

@@ -0,0 +1,298 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: absolute;
top: 6px;
right: 6px;
z-index: 10;
.ai-assist-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid transparent;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
opacity: 0.7;
&:hover,
&.open {
opacity: 1;
color: ${(props) => props.theme.colors.accent};
background: ${(props) => props.theme.colors.accent}10;
border-color: ${(props) => props.theme.input.border};
}
&:focus-visible {
outline: 2px solid ${(props) => props.theme.colors.accent}55;
outline-offset: 1px;
}
}
.ai-assist-popup {
position: absolute;
top: calc(100% + 4px);
right: 0;
width: 360px;
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
overflow: hidden;
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid ${(props) => props.theme.input.border};
}
.popup-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.text};
text-transform: uppercase;
letter-spacing: 0.05em;
svg {
color: ${(props) => props.theme.colors.accent};
}
}
.popup-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: ${(props) => props.theme.border.radius.sm};
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
}
}
.popup-body {
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.popup-input {
width: 100%;
padding: 8px 10px;
font-size: 12px;
font-family: inherit;
line-height: 1.4;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
resize: vertical;
outline: none;
transition: border-color 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.85;
}
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.popup-suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.suggestion-chip {
padding: 3px 8px;
font-size: 11px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 999px;
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
&:hover:not(:disabled) {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.colors.accent}80;
background: ${(props) => props.theme.colors.accent}10;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.popup-error {
padding: 6px 8px;
font-size: 11px;
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.colors.bg.danger}15;
}
.popup-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-top: 1px solid ${(props) => props.theme.input.border};
}
.popup-hint {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.popup-loading {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
}
.loading-spinner {
width: 12px;
height: 12px;
border: 2px solid ${(props) => props.theme.input.border};
border-top-color: ${(props) => props.theme.colors.accent};
border-radius: 50%;
animation: ai-assist-spin 0.7s linear infinite;
}
@keyframes ai-assist-spin {
to { transform: rotate(360deg); }
}
.btn-generate {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.colors.accent};
background: ${(props) => props.theme.colors.accent};
color: white;
cursor: pointer;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.88;
}
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.btn-secondary {
padding: 5px 12px;
font-size: 12px;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.input.border};
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
transition: background-color 0.15s ease;
&:hover:not(:disabled) {
background: ${(props) => props.theme.input.bg};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.preview-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-label {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
text-transform: uppercase;
letter-spacing: 0.05em;
}
.preview-code {
max-height: 220px;
overflow: auto;
padding: 8px 10px;
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
font-size: 11.5px;
line-height: 1.5;
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
white-space: pre;
}
.preview-modes {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.preview-mode-btn {
padding: 2px 6px;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid transparent;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
font-size: 11px;
&.active {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.input.border};
background: ${(props) => props.theme.input.bg};
}
&:hover:not(.active) {
color: ${(props) => props.theme.text};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,232 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import get from 'lodash/get';
import { IconStars, IconX, IconArrowBackUp } from '@tabler/icons';
import { aiGenerateScript } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
const SUGGESTIONS = {
'tests': [
{ label: 'Status 200', prompt: 'Add a test asserting the response status code is 200' },
{ label: 'JSON body', prompt: 'Add tests validating the JSON response body structure and key fields' },
{ label: 'Headers', prompt: 'Add a test checking the content-type response header' },
{ label: 'Response time', prompt: 'Add a test asserting the response time is below 1000ms' }
],
'pre-request': [
{ label: 'Auth header', prompt: 'Set an Authorization header from an environment token variable' },
{ label: 'Timestamp', prompt: 'Set a variable named "timestamp" containing the current epoch ms' },
{ label: 'Random ID', prompt: 'Set a variable named "requestId" containing a random UUID-style id' }
],
'post-response': [
{ label: 'Save token', prompt: 'Extract a token from the response body and save it to an environment variable' },
{ label: 'Save id', prompt: 'Extract the primary id from the response body and save it to a variable' },
{ label: 'Log response', prompt: 'Log the response status and a short summary of the body' }
]
};
const TITLES = {
'tests': 'Generate Tests',
'pre-request': 'Generate Pre-Request Script',
'post-response': 'Generate Post-Response Script'
};
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
const [isOpen, setIsOpen] = useState(false);
const [prompt, setPrompt] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [generated, setGenerated] = useState(null);
const buttonRef = useRef(null);
const focusOnMount = useCallback((el) => {
el?.focus();
}, []);
const preferences = useSelector((state) => state.app.preferences);
const isAiEnabled = get(preferences, 'ai.enabled', false);
const suggestions = useMemo(() => SUGGESTIONS[scriptType] || [], [scriptType]);
const title = TITLES[scriptType] || 'Generate with AI';
const close = useCallback(() => {
setIsOpen(false);
setError(null);
}, []);
const attachPopup = useCallback((el) => {
if (!el) return undefined;
const onDocMouseDown = (e) => {
if (!el.contains(e.target) && !buttonRef.current?.contains(e.target)) {
close();
}
};
const onKey = (e) => {
if (e.key === 'Escape') close();
};
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onKey);
};
}, [close]);
const handleGenerate = useCallback(
async (overridePrompt) => {
const text = (overridePrompt ?? prompt).trim();
if (!text || isLoading) return;
setIsLoading(true);
setError(null);
try {
const result = await aiGenerateScript({
scriptType,
prompt: text,
currentScript: currentScript || '',
requestContext
});
if (result?.error) {
setError(result.error);
return;
}
if (result?.content) {
setGenerated(result.content);
} else {
setError('No content was generated. Try rephrasing your prompt.');
}
} catch (err) {
setError(err?.message || 'Failed to generate script');
} finally {
setIsLoading(false);
}
},
[prompt, isLoading, scriptType, currentScript, requestContext]
);
const handleApply = useCallback(() => {
if (generated == null) return;
onApply(generated);
setGenerated(null);
setPrompt('');
close();
}, [generated, onApply, close]);
const handleBackToPrompt = useCallback(() => {
setGenerated(null);
setError(null);
}, []);
if (!isAiEnabled || !isValidType(scriptType)) return null;
return (
<StyledWrapper>
<button
ref={buttonRef}
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
onClick={() => setIsOpen((v) => !v)}
title={title}
type="button"
aria-label={title}
>
<IconStars size={14} strokeWidth={1.75} />
</button>
{isOpen && (
<div ref={attachPopup} className="ai-assist-popup" role="dialog" aria-label={title}>
<div className="popup-header">
<span className="popup-title">
<IconStars size={12} strokeWidth={1.75} />
{title}
</span>
<button className="popup-close" onClick={close} type="button" aria-label="Close">
<IconX size={14} />
</button>
</div>
{generated == null ? (
<>
<div className="popup-body">
<textarea
ref={focusOnMount}
className="popup-input"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleGenerate();
}
}}
placeholder="Describe what you want to generate..."
rows={3}
disabled={isLoading}
/>
{!isLoading && !prompt && suggestions.length > 0 && (
<div className="popup-suggestions">
{suggestions.map((s) => (
<button
key={s.label}
className="suggestion-chip"
type="button"
onClick={() => handleGenerate(s.prompt)}
disabled={isLoading}
>
{s.label}
</button>
))}
</div>
)}
{error && <div className="popup-error">{error}</div>}
</div>
<div className="popup-footer">
{isLoading ? (
<span className="popup-loading">
<span className="loading-spinner" />
Generating...
</span>
) : (
<span className="popup-hint"> + Enter to generate</span>
)}
<button
className="btn-generate"
type="button"
onClick={() => handleGenerate()}
disabled={!prompt.trim() || isLoading}
>
Generate
</button>
</div>
</>
) : (
<>
<div className="popup-body">
<div className="preview-section">
<span className="preview-label">Preview · replaces current script</span>
<pre className="preview-code">{generated}</pre>
</div>
</div>
<div className="popup-footer">
<button className="btn-secondary" type="button" onClick={handleBackToPrompt}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<IconArrowBackUp size={12} /> Back
</span>
</button>
<button className="btn-generate" type="button" onClick={handleApply}>
Apply
</button>
</div>
</>
)}
</div>
)}
</StyledWrapper>
);
};
export default AIAssist;

View File

@@ -1,12 +1,72 @@
import { memo } from 'react';
import SwaggerUI from 'swagger-ui-react';
import StyledWrapper from './StyledWrapper';
import { serializeBody } from './serializeBody';
const serializeHeaders = (headers) => {
if (!headers) return {};
if (typeof headers.entries === 'function') {
const out = {};
for (const [k, v] of headers.entries()) out[k] = v;
return out;
}
return { ...headers };
};
const proxiedFetch = async (url, options = {}) => {
const result = await window.ipcRenderer.invoke('renderer:swagger-fetch', {
url,
method: options.method || 'GET',
headers: serializeHeaders(options.headers),
body: serializeBody(options.body)
});
if (result.error) {
const err = new TypeError(result.message);
err.code = result.code;
throw err;
}
// The Response constructor throws if a null-body status carries a body.
const nullBodyStatus = [101, 204, 205, 304].includes(result.status);
const bodyBytes = !nullBodyStatus && result.bodyBase64
? Uint8Array.from(atob(result.bodyBase64), (c) => c.charCodeAt(0))
: null;
// Build Headers manually so multi-value response headers (e.g. Set-Cookie,
// which axios returns as string[]) end up as repeated entries rather than
// joined via toString(). new Headers({ 'set-cookie': ['a','b'] }) coerces
// the array to "a,b", which is invalid Set-Cookie syntax.
const responseHeaders = new Headers();
for (const [name, value] of Object.entries(result.headers || {})) {
if (Array.isArray(value)) {
value.forEach((v) => responseHeaders.append(name, String(v)));
} else if (value != null) {
responseHeaders.append(name, String(value));
}
}
return new Response(bodyBytes, {
status: result.status,
statusText: result.statusText,
headers: responseHeaders
});
};
const requestInterceptor = (req) => {
req.userFetch = proxiedFetch;
return req;
};
const Swagger = ({ spec, onComplete }) => {
return (
<StyledWrapper>
<div className="swagger-root w-full">
<SwaggerUI spec={spec} onComplete={onComplete} />
<SwaggerUI
spec={spec}
onComplete={onComplete}
requestInterceptor={requestInterceptor}
/>
</div>
</StyledWrapper>
);

View File

@@ -0,0 +1,83 @@
// Serializes a SwaggerUI fetch body for transport across the renderer ↔ main
// IPC bridge in `renderer:swagger-fetch`. Only types that survive Electron's
// structured-clone serialization (and that our axios bridge knows how to send
// as an HTTP body) are supported. Multipart / binary types throw so the user
// gets a clear message in the SwaggerUI response panel instead of a silent
// failure.
const detectBodyType = (body) => {
if (body == null) return 'null';
if (typeof body === 'string') return 'string';
if (typeof FormData !== 'undefined' && body instanceof FormData) return 'FormData';
if (typeof File !== 'undefined' && body instanceof File) return 'File';
if (typeof Blob !== 'undefined' && body instanceof Blob) return 'Blob';
if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) return 'URLSearchParams';
if (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer) return 'ArrayBuffer';
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) return body.constructor?.name || 'TypedArray';
if (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) return 'ReadableStream';
return typeof body;
};
export const UNSUPPORTED_BODY_TYPE_CODE = 'UNSUPPORTED_BODY_TYPE';
// Mapping from Web API class name (the raw detected type) to the user-facing
// subject used in the error message. SwaggerUI itself supports these body
// types fine; the limitation is Bruno's renderer↔main IPC bridge, not Swagger.
const BODY_TYPE_LABEL_MAP = {
File: 'File upload',
Blob: 'Binary file upload',
FormData: 'Multipart form data',
ArrayBuffer: 'Binary data',
ReadableStream: 'Streaming upload'
};
const mapBodyTypeToLabel = (typeName) => {
if (BODY_TYPE_LABEL_MAP[typeName]) return BODY_TYPE_LABEL_MAP[typeName];
// TypedArrays (Uint8Array, Float32Array, etc.) share a label.
if (typeof typeName === 'string' && typeName.endsWith('Array')) return 'Binary data';
return 'This request body type';
};
export const UNSUPPORTED_BODY_MESSAGE = (typeName) =>
`${mapBodyTypeToLabel(typeName)} via the Swagger Try-it-out panel isn't supported in Bruno yet. `
+ `Supported body types: JSON, URL-encoded forms, plain text. `
+ `Create a Bruno request to test this endpoint.`;
// Build a TypeError that carries the detected type as a property so downstream
// catchers can branch on `err.code` / `err.bodyType` instead of regex-parsing
// the message. `err.bodyType` keeps the raw Web API class name for diagnostics;
// the user-visible message uses the friendly subject above.
const unsupportedBodyError = (typeName) => {
const err = new TypeError(UNSUPPORTED_BODY_MESSAGE(typeName));
err.code = UNSUPPORTED_BODY_TYPE_CODE;
err.bodyType = typeName;
return err;
};
export const serializeBody = (body) => {
const typeName = detectBodyType(body);
switch (typeName) {
case 'null':
return undefined;
case 'string':
return body;
case 'URLSearchParams':
return body.toString();
case 'FormData':
case 'File':
case 'Blob':
case 'ArrayBuffer':
case 'ReadableStream':
throw unsupportedBodyError(typeName);
default:
// TypedArrays land here (Uint8Array, etc.) — also unsupported by the bridge.
if (ArrayBuffer.isView && ArrayBuffer.isView(body)) {
throw unsupportedBodyError(typeName);
}
// Plain objects, numbers, booleans — pass through. SwaggerUI rarely sends
// these as body directly (it stringifies JSON before fetch), but keep the
// path open rather than rejecting unexpectedly.
return body;
}
};

View File

@@ -0,0 +1,95 @@
import { serializeBody, UNSUPPORTED_BODY_MESSAGE, UNSUPPORTED_BODY_TYPE_CODE } from './serializeBody';
// Helper: invoke serializeBody and return the thrown error (or fail).
const catchSerializeError = (body) => {
try {
serializeBody(body);
} catch (err) {
return err;
}
throw new Error('expected serializeBody to throw');
};
describe('serializeBody', () => {
describe('supported body types', () => {
it('returns undefined for null', () => {
expect(serializeBody(null)).toBeUndefined();
});
it('returns undefined for undefined', () => {
expect(serializeBody(undefined)).toBeUndefined();
});
it('returns string bodies as-is', () => {
expect(serializeBody('{"name":"doggie"}')).toBe('{"name":"doggie"}');
expect(serializeBody('plain text')).toBe('plain text');
});
it('stringifies URLSearchParams', () => {
const params = new URLSearchParams({ a: '1', b: '2' });
expect(serializeBody(params)).toBe('a=1&b=2');
});
});
describe('unsupported body types (BRU-3300)', () => {
it('throws TypeError for FormData using "Multipart form data" subject', () => {
const fd = new FormData();
fd.append('file', new Blob(['x']));
expect(() => serializeBody(fd)).toThrow(TypeError);
expect(() => serializeBody(fd)).toThrow(/Multipart form data/);
expect(() => serializeBody(fd)).toThrow(/Create a Bruno request/);
});
it('throws TypeError for Blob using "Binary file upload" subject', () => {
const blob = new Blob(['payload']);
expect(() => serializeBody(blob)).toThrow(TypeError);
expect(() => serializeBody(blob)).toThrow(/Binary file upload/);
});
it('throws TypeError for File using "File upload" subject', () => {
const file = new File(['payload'], 'test.txt', { type: 'text/plain' });
expect(() => serializeBody(file)).toThrow(TypeError);
expect(() => serializeBody(file)).toThrow(/File upload/);
});
it('throws TypeError for ArrayBuffer using "Binary data" subject', () => {
const buf = new ArrayBuffer(8);
expect(() => serializeBody(buf)).toThrow(TypeError);
expect(() => serializeBody(buf)).toThrow(/Binary data/);
});
it('throws TypeError for TypedArray using "Binary data" subject', () => {
const u8 = new Uint8Array([1, 2, 3]);
expect(() => serializeBody(u8)).toThrow(TypeError);
expect(() => serializeBody(u8)).toThrow(/Binary data/);
});
it('message attributes the limitation to Bruno, not Swagger', () => {
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/isn't supported in Bruno yet/);
});
it('message lists supported alternatives', () => {
expect(UNSUPPORTED_BODY_MESSAGE('FormData')).toMatch(/JSON, URL-encoded forms, plain text/);
});
});
describe('error metadata preservation (Bijin review feedback)', () => {
it('attaches err.code = UNSUPPORTED_BODY_TYPE so callers can branch programmatically', () => {
const err = catchSerializeError(new FormData());
expect(err.code).toBe(UNSUPPORTED_BODY_TYPE_CODE);
expect(UNSUPPORTED_BODY_TYPE_CODE).toBe('UNSUPPORTED_BODY_TYPE');
});
it('attaches err.bodyType naming the specific unsupported type', () => {
expect(catchSerializeError(new FormData()).bodyType).toBe('FormData');
expect(catchSerializeError(new Blob(['x'])).bodyType).toBe('Blob');
expect(catchSerializeError(new File(['x'], 'a.txt')).bodyType).toBe('File');
expect(catchSerializeError(new ArrayBuffer(4)).bodyType).toBe('ArrayBuffer');
expect(catchSerializeError(new Uint8Array([1, 2])).bodyType).toBe('Uint8Array');
});
it('thrown error is still a TypeError instance', () => {
expect(catchSerializeError(new FormData())).toBeInstanceOf(TypeError);
});
});
});

View File

@@ -52,6 +52,14 @@ const AppTitleBar = () => {
const { ipcRenderer } = window;
if (!ipcRenderer) return;
ipcRenderer.invoke('renderer:window-is-fullscreen')
.then((fullscreen) => {
setIsFullScreen(fullscreen);
})
.catch((error) => {
console.error('Error getting initial fullscreen state:', error);
});
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
setIsFullScreen(true);
});
@@ -144,8 +152,10 @@ const AppTitleBar = () => {
const handleOpenWorkspace = async () => {
try {
await dispatch(openWorkspaceDialog());
toast.success('Workspace opened successfully');
const result = await dispatch(openWorkspaceDialog());
if (result) {
toast.success('Workspace opened successfully');
}
} catch (error) {
toast.error(error.message || 'Failed to open workspace');
}

View File

@@ -0,0 +1,128 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, waitFor, act } from '@testing-library/react';
import { ThemeProvider } from 'styled-components';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
jest.mock('ui/MenuDropdown', () => ({ children }) => <div>{children}</div>);
jest.mock('ui/ActionIcon', () => ({ children, onClick, label }) => (
<button onClick={onClick} aria-label={label}>{children}</button>
));
jest.mock('components/ResponsePane/ResponseLayoutToggle', () => () => null);
import AppTitleBar from './index';
const theme = {
text: '#333',
sidebar: {
bg: '#fff',
color: '#333',
muted: '#888',
collection: { item: { hoverBg: '#eee' } }
},
dropdown: { color: '#333', mutedText: '#888', hoverBg: '#eee' }
};
const mockStore = configureStore({
reducer: {
workspaces: (state = { workspaces: [], activeWorkspaceUid: null }) => state,
app: (state = { preferences: {}, sidebarCollapsed: false }) => state,
logs: (state = { isConsoleOpen: false }) => state
}
});
const renderWithProviders = () => render(
<Provider store={mockStore}>
<ThemeProvider theme={theme}>
<AppTitleBar />
</ThemeProvider>
</Provider>
);
const getTitleBar = (container) => container.querySelector('.app-titlebar');
const mockInvokeWithFullscreen = (isFullScreen) => jest.fn((channel) => {
if (channel === 'renderer:window-is-fullscreen') return Promise.resolve(isFullScreen);
return Promise.resolve(false);
});
describe('AppTitleBar — fullscreen state sync', () => {
let ipcListeners;
beforeEach(() => {
ipcListeners = {};
window.ipcRenderer = {
invoke: jest.fn().mockResolvedValue(false),
send: jest.fn(),
on: jest.fn((channel, cb) => {
ipcListeners[channel] = cb;
return jest.fn();
})
};
});
afterEach(() => {
delete window.ipcRenderer;
});
describe('initial state on mount', () => {
it('should query the main process for current fullscreen state', async () => {
renderWithProviders();
await waitFor(() => {
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:window-is-fullscreen');
});
});
it('should apply fullscreen class when window is already fullscreen at mount', async () => {
window.ipcRenderer.invoke = mockInvokeWithFullscreen(true);
const { container } = renderWithProviders();
await waitFor(() => {
expect(getTitleBar(container)).toHaveClass('fullscreen');
});
});
it('should not apply fullscreen class when window is windowed at mount', async () => {
const { container } = renderWithProviders();
await waitFor(() => {
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:window-is-fullscreen');
});
expect(getTitleBar(container)).not.toHaveClass('fullscreen');
});
});
describe('fullscreen transitions after mount', () => {
it('should add fullscreen class on main:enter-full-screen event', async () => {
const { container } = renderWithProviders();
await waitFor(() => {
expect(window.ipcRenderer.invoke).toHaveBeenCalledWith('renderer:window-is-fullscreen');
});
act(() => {
ipcListeners['main:enter-full-screen']();
});
expect(getTitleBar(container)).toHaveClass('fullscreen');
});
it('should remove fullscreen class on main:leave-full-screen event', async () => {
window.ipcRenderer.invoke = mockInvokeWithFullscreen(true);
const { container } = renderWithProviders();
await waitFor(() => {
expect(getTitleBar(container)).toHaveClass('fullscreen');
});
act(() => {
ipcListeners['main:leave-full-screen']();
});
expect(getTitleBar(container)).not.toHaveClass('fullscreen');
});
});
});

View File

@@ -16,6 +16,7 @@ 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 { setupCodeMirrorResizeRefresh } from 'utils/codemirror/resize';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
import {
applyEditorState,
@@ -64,13 +65,16 @@ class CodeEditor extends React.Component {
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
const runShortcut = () => {
if (this.props.onRun) {
this.props.onRun();
return;
}
return CodeMirror.Pass;
};
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
@@ -266,6 +270,8 @@ class CodeEditor extends React.Component {
if (cmInput) {
cmInput.classList.add('mousetrap');
}
this.cleanupResizeRefresh = setupCodeMirrorResizeRefresh(editor, this._node);
}
}
@@ -399,6 +405,7 @@ class CodeEditor extends React.Component {
// Clean up lint error tooltip
this.cleanupLintErrorTooltip?.();
this.cleanupResizeRefresh?.();
const wrapper = this.editor.getWrapperElement();
wrapper?.parentNode?.removeChild(wrapper);

View File

@@ -75,13 +75,13 @@ const AuthMode = ({ collection }) => {
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
>
<div className="flex items-center justify-center auth-mode-label select-none">
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>

View File

@@ -5,10 +5,11 @@ import { updateCollectionPresets } from 'providers/ReduxStore/slices/collections
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { get } from 'lodash';
import Button from 'ui/Button';
import { DEFAULT_PRESET_REQUEST_TYPE, PRESET_REQUEST_TYPES } from 'utils/common/constants';
const PresetsSettings = ({ collection }) => {
const dispatch = useDispatch();
const initialPresets = { requestType: 'http', requestUrl: '' };
const initialPresets = { requestType: DEFAULT_PRESET_REQUEST_TYPE, requestUrl: '' };
// Get presets from draft.brunoConfig if it exists, otherwise from brunoConfig
const currentPresets = collection.draft?.brunoConfig
@@ -47,12 +48,13 @@ const PresetsSettings = ({ collection }) => {
<div className="flex items-center">
<input
id="http"
data-testid="presets-request-type-http"
className="cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="http"
checked={(currentPresets.requestType || 'http') === 'http'}
value={PRESET_REQUEST_TYPES.HTTP}
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.HTTP}
/>
<label htmlFor="http" className="ml-1 cursor-pointer select-none">
HTTP
@@ -60,12 +62,13 @@ const PresetsSettings = ({ collection }) => {
<input
id="graphql"
data-testid="presets-request-type-graphql"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="graphql"
checked={(currentPresets.requestType || 'http') === 'graphql'}
value={PRESET_REQUEST_TYPES.GRAPHQL}
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRAPHQL}
/>
<label htmlFor="graphql" className="ml-1 cursor-pointer select-none">
GraphQL
@@ -73,12 +76,13 @@ const PresetsSettings = ({ collection }) => {
<input
id="grpc"
data-testid="presets-request-type-grpc"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="grpc"
checked={(currentPresets.requestType || 'http') === 'grpc'}
value={PRESET_REQUEST_TYPES.GRPC}
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.GRPC}
/>
<label htmlFor="grpc" className="ml-1 cursor-pointer select-none">
gRPC
@@ -86,12 +90,13 @@ const PresetsSettings = ({ collection }) => {
<input
id="ws"
data-testid="presets-request-type-ws"
className="ml-4 cursor-pointer"
type="radio"
name="requestType"
onChange={handleRequestTypeChange}
value="ws"
checked={(currentPresets.requestType || 'http') === 'ws'}
value={PRESET_REQUEST_TYPES.WS}
checked={(currentPresets.requestType || DEFAULT_PRESET_REQUEST_TYPE) === PRESET_REQUEST_TYPES.WS}
/>
<label htmlFor="ws" className="ml-1 cursor-pointer select-none">
WebSocket
@@ -106,6 +111,7 @@ const PresetsSettings = ({ collection }) => {
<div className="flex items-center flex-grow input-container h-full">
<input
id="request-url"
data-testid="presets-request-url"
type="text"
name="requestUrl"
placeholder="Request URL"
@@ -123,7 +129,7 @@ const PresetsSettings = ({ collection }) => {
</div>
<div className="mt-6">
<Button type="button" size="sm" onClick={handleSave}>
<Button type="button" size="sm" data-testid="presets-save-btn" onClick={handleSave}>
Save
</Button>
</div>

View File

@@ -3,6 +3,7 @@ import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -108,39 +109,53 @@ const Script = ({ collection }) => {
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
onApply={onRequestScriptEdit}
/>
</div>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
onApply={onResponseScriptEdit}
/>
</div>
</TabsContent>
</Tabs>

View File

@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { updateCollectionTests } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -32,21 +33,24 @@ const Tests = ({ collection }) => {
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="collection-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="collection-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -15,6 +15,7 @@ import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import StatusDot from 'components/StatusDot';
import Overview from './Overview/index';
import { DEFAULT_PRESET_REQUEST_TYPE } from 'utils/common/constants';
const CollectionSettings = ({ collection }) => {
const dispatch = useDispatch();
@@ -60,7 +61,7 @@ const CollectionSettings = ({ collection }) => {
? get(collection, 'draft.brunoConfig.protobuf', {})
: get(collection, 'brunoConfig.protobuf', {});
const presets = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.presets', {}) : get(collection, 'brunoConfig.presets', {});
const hasPresets = presets && presets.requestUrl !== '';
const hasPresets = presets && ((presets.requestType && presets.requestType !== DEFAULT_PRESET_REQUEST_TYPE) || (presets.requestUrl && presets.requestUrl !== ''));
const getTabPanel = (tab) => {
switch (tab) {

View File

@@ -168,6 +168,17 @@ const EditableTable = ({
};
}, [defaultRow, checkboxKey]);
const hasAnyValue = useCallback((row) => {
for (const col of columns) {
const val = col.getValue ? col.getValue(row) : row[col.key];
const defaultVal = defaultRow[col.key];
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
return true;
}
}
return false;
}, [columns, defaultRow]);
const rowsWithEmpty = useMemo(() => {
if (!showAddRow) {
return rows;
@@ -177,16 +188,11 @@ const EditableTable = ({
return [createEmptyRow()];
}
const lastRow = rows[rows.length - 1];
const keyColumn = columns.find((col) => col.isKeyField);
if (keyColumn) {
const lastRowKeyValue = keyColumn.getValue ? keyColumn.getValue(lastRow) : lastRow[keyColumn.key];
const isLastRowEmpty = !lastRowKeyValue || (typeof lastRowKeyValue === 'string' && lastRowKeyValue.trim() === '');
if (isLastRowEmpty) {
return rows;
}
// If the last row is already empty (e.g. a stray empty row loaded from a
// pre-existing file), don't append another one — otherwise the table would
// render two empty rows at the bottom on the initial render.
if (!hasAnyValue(rows[rows.length - 1])) {
return rows;
}
if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) {
@@ -198,15 +204,11 @@ const EditableTable = ({
[checkboxKey]: true,
...defaultRow
}];
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, showAddRow]);
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, hasAnyValue, showAddRow]);
const isEmptyRow = useCallback((row) => {
const keyColumn = columns.find((col) => col.isKeyField);
if (!keyColumn) return false;
const value = keyColumn.getValue ? keyColumn.getValue(row) : row[keyColumn.key];
return !value || (typeof value === 'string' && value.trim() === '');
}, [columns]);
// A row is empty when none of its columns hold a value — the single source of
// truth used everywhere (memo guard, persistence filter, last-row rendering).
const isEmptyRow = useCallback((row) => !hasAnyValue(row), [hasAnyValue]);
const isLastEmptyRow = useCallback((row, index) => {
if (!showAddRow) return false;
@@ -227,50 +229,20 @@ const EditableTable = ({
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
if (rowIndex === -1) return;
const currentRow = rowsWithEmpty[rowIndex];
const isLast = rowIndex === rowsWithEmpty.length - 1;
const wasEmpty = isEmptyRow(currentRow);
const keyColumn = columns.find((col) => col.isKeyField);
const isKeyFieldChange = keyColumn && keyColumn.key === key;
let updatedRows = rowsWithEmpty.map((row) => {
const updatedRows = rowsWithEmpty.map((row) => {
if (row.uid === rowUid) {
return { ...row, [key]: value };
}
return row;
});
// Only add a new empty row when the key field is filled
if (showAddRow && isLast && wasEmpty && isKeyFieldChange && value && value.trim() !== '') {
emptyRowUidRef.current = uuid();
updatedRows.push({
uid: emptyRowUidRef.current,
[checkboxKey]: true,
...defaultRow
});
}
const hasAnyValue = (row) => {
for (const col of columns) {
const val = col.getValue ? col.getValue(row) : row[col.key];
const defaultVal = defaultRow[col.key];
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
return true;
}
}
return false;
};
const result = updatedRows.filter((row, i) => {
if (showAddRow && i === updatedRows.length - 1) {
return hasAnyValue(row);
}
return true;
});
// Remove any fully-empty rows from the persisted data. The trailing empty
// "add row" is re-added by the rowsWithEmpty memo, so there's always
// exactly one empty row at the bottom and never a stray empty row above it.
const result = showAddRow ? updatedRows.filter(hasAnyValue) : updatedRows;
onChange(result);
}, [rowsWithEmpty, columns, onChange, checkboxKey, defaultRow, isEmptyRow, showAddRow]);
}, [rowsWithEmpty, hasAnyValue, onChange, showAddRow]);
const handleCheckboxChange = useCallback((rowUid, checked) => {
handleValueChange(rowUid, checkboxKey, checked);

View File

@@ -151,17 +151,21 @@ const EnvironmentVariablesTable = ({
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
if (_collection) {
_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 workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables;
// `_collection` flows into every row's MultiLineEditor as the variable-resolution
// context. Without memoization, `cloneDeep(collection)` runs on every render —
// and Formik triggers a re-render on every keystroke, so a single env edit
// session can deep-clone the entire collection 100+ times. That's the
// dominant cost behind the test-budget flake.
const _collection = useMemo(() => {
const c = collection ? cloneDeep(collection) : {};
c.globalEnvironmentVariables = globalEnvironmentVariables;
if (!collection && workspaceProcessEnvVariables) {
c.workspaceProcessEnvVariables = workspaceProcessEnvVariables;
}
return c;
}, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables]);
const initialValues = useMemo(() => {
const vars = environment.variables || [];

View File

@@ -1,5 +1,5 @@
import React from 'react';
import path from 'utils/common/path';
import { getRelativePathWithinBasePath } from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import { IconX, IconUpload, IconFile } from '@tabler/icons';
@@ -48,13 +48,7 @@ const FilePickerEditor = ({
// If file is in the collection's directory, then we use relative path
// Otherwise, we use the absolute path
filePaths = filePaths.map((filePath) => {
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
return path.relative(collectionDir, filePath);
}
return filePath;
return getRelativePathWithinBasePath(collection.pathname, filePath);
});
onChange(isSingleFilePicker ? filePaths[0] : filePaths);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import get from 'lodash/get';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
@@ -18,8 +18,9 @@ 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';
import { humanizeRequestAuthMode, getTreePathFromCollectionToItem } from 'utils/collections/index';
import { humanizeRequestAuthMode } from 'utils/collections/index';
import Button from 'ui/Button';
import { getEffectiveAuthSource } from 'utils/auth';
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
const dispatch = useDispatch();
@@ -52,41 +53,6 @@ const Auth = ({ collection, folder }) => {
let request = get(folderRoot, 'request', {});
const authMode = get(folderRoot, 'request.auth.mode');
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Get path from collection to current folder
const folderTreePath = getTreePathFromCollectionToItem(collection, folder);
// Check parent folders to find closest auth configuration
// Skip the last item which is the current folder
for (let i = 0; i < folderTreePath.length - 1; i++) {
const parentFolder = folderTreePath[i];
if (parentFolder.type === 'folder') {
const parentFolderRoot = parentFolder?.draft || parentFolder?.root;
const folderAuth = get(parentFolderRoot, 'request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: parentFolder.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const handleSave = () => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
@@ -98,6 +64,11 @@ const Auth = ({ collection, folder }) => {
});
};
const inheritedSource = useMemo(
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, folder) : null),
[authMode, folder, collection]
);
const getAuthView = () => {
switch (authMode) {
case 'basic': {
@@ -202,12 +173,11 @@ const Auth = ({ collection, folder }) => {
);
}
case 'inherit': {
const source = getEffectiveAuthSource();
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
<div>Auth inherited from {inheritedSource.name}: </div>
<div className="inherit-mode-text" data-testid="inherited-auth-mode">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
</div>
</>
);

View File

@@ -81,14 +81,15 @@ const AuthMode = ({ collection, folder }) => {
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
showTickMark={true}
data-testid="auth-mode-dropdown"
>
<div className="flex items-center justify-center auth-mode-label select-none">
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>

View File

@@ -3,6 +3,7 @@ import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -111,39 +112,53 @@ const Script = ({ collection, folder }) => {
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
onApply={onRequestScriptEdit}
/>
</div>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
onApply={onResponseScriptEdit}
/>
</div>
</TabsContent>
</Tabs>

View File

@@ -2,6 +2,7 @@ import React, { useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { updateFolderTests } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -33,21 +34,24 @@ const Tests = ({ collection, folder }) => {
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="folder-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="folder-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import classnames from 'classnames';
import { updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
@@ -10,7 +10,7 @@ import Vars from './Vars';
import Documentation from './Documentation';
import Auth from './Auth';
import StatusDot from 'components/StatusDot';
import get from 'lodash/get';
import { hasEffectiveAuth } from 'utils/auth';
const FolderSettings = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -31,8 +31,11 @@ const FolderSettings = ({ collection, folder }) => {
const responseVars = folderRoot?.request?.vars?.res || [];
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(folderRoot, 'request.auth.mode');
const hasAuth = auth && auth !== 'none';
const folderAuthMode = folder?.draft?.request?.auth?.mode ?? folder?.root?.request?.auth?.mode;
const hasAuth = useMemo(
() => hasEffectiveAuth(collection, folder),
[folder, folderAuthMode, collection]
);
const setTab = (tab) => {
dispatch(
@@ -95,7 +98,7 @@ const FolderSettings = ({ collection, folder }) => {
</div>
<div className={getTabClassname('auth')} role="tab" data-testid="folder-settings-tab-auth" onClick={() => setTab('auth')}>
Auth
{hasAuth && <StatusDot />}
{hasAuth && <StatusDot dataTestId="auth" />}
</div>
<div className={getTabClassname('docs')} role="tab" data-testid="folder-settings-tab-docs" onClick={() => setTab('docs')}>
Docs

View File

@@ -16,6 +16,7 @@ import StyledWrapper from './StyledWrapper';
import MenuDropdown from 'ui/MenuDropdown/index';
import Button from 'ui/Button';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
const ManageWorkspace = () => {
const dispatch = useDispatch();
@@ -157,6 +158,7 @@ const ManageWorkspace = () => {
<MenuDropdown
placement="bottom-end"
items={[
{ id: 'open-in-terminal', label: 'Open in Terminal', onClick: () => openDevtoolsAndSwitchToTerminal(dispatch, workspace.pathname) },
{ id: 'rename', label: 'Rename', onClick: () => handleRenameClick(workspace) },
{ id: 'remove', label: 'Remove', onClick: () => handleCloseClick(workspace) }
]}

View File

@@ -28,7 +28,9 @@ const ModalFooter = ({
confirmDisabled,
hideCancel,
hideFooter,
confirmButtonColor = 'primary'
footerLeft,
confirmButtonColor = 'primary',
dataTestId = 'modal'
}) => {
confirmText = confirmText || 'Save';
cancelText = cancelText || 'Cancel';
@@ -38,23 +40,27 @@ const ModalFooter = ({
}
return (
<div className="flex justify-end p-4 bruno-modal-footer">
<span className={hideCancel ? 'hidden' : 'mr-2'}>
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
{cancelText}
</Button>
</span>
<span>
<Button
type="submit"
color={confirmButtonColor}
disabled={confirmDisabled}
onClick={handleSubmit}
className="submit"
>
{confirmText}
</Button>
</span>
<div className="flex justify-between items-center p-4 bruno-modal-footer">
<div>{footerLeft}</div>
<div className="flex justify-end">
<span className={hideCancel ? 'hidden' : 'mr-2'}>
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
{cancelText}
</Button>
</span>
<span>
<Button
type="submit"
color={confirmButtonColor}
disabled={confirmDisabled}
onClick={handleSubmit}
className="submit"
data-testid={`${dataTestId}-submit-btn`}
>
{confirmText}
</Button>
</span>
</div>
</div>
);
};
@@ -72,6 +78,7 @@ const Modal = ({
hideCancel,
hideFooter,
hideClose,
footerLeft,
disableCloseOnOutsideClick,
disableEscapeKey,
onClick,
@@ -150,7 +157,9 @@ const Modal = ({
confirmDisabled={confirmDisabled}
hideCancel={hideCancel}
hideFooter={hideFooter}
footerLeft={footerLeft}
confirmButtonColor={confirmButtonColor}
dataTestId={dataTestId}
/>
</div>

View File

@@ -30,13 +30,16 @@ class MultiLineEditor extends Component {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
const variables = getAllVariables(this.props.collection, this.props.item);
const runShortcut = () => {
if (this.props.onRun) {
this.props.onRun();
return;
}
return CodeMirror.Pass;
};
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,

View File

@@ -0,0 +1,202 @@
import styled from 'styled-components';
const Wrapper = styled.div`
width: 100%;
display: flex;
align-items: center;
min-width: 0;
position: relative;
.file-chips-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
overflow: hidden;
}
.file-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
border-radius: 6px;
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
font-size: 12px;
line-height: 1;
color: ${(props) => props.theme.text};
max-width: 140px;
min-width: 75px;
flex: 0 1 auto;
white-space: nowrap;
}
.file-chip-icon {
flex: 0 0 auto;
color: ${(props) => props.theme.colors.text.muted};
}
.file-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
}
.file-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 1px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 3px;
flex: 0 0 auto;
&:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
.file-more-chip {
display: inline-flex;
align-items: center;
padding: 2px 4px;
background: transparent;
border: none;
font-size: 12px;
line-height: 1;
color: ${(props) => props.theme.primary.text};
cursor: pointer;
flex: 0 0 auto;
white-space: nowrap;
&:hover {
color: ${(props) => props.theme.primary.text};
opacity: 0.8;
}
}
.file-summary-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
border-radius: 6px;
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
font-size: 12px;
line-height: 1;
color: ${(props) => props.theme.text};
cursor: pointer;
flex: 0 1 auto;
min-width: 0;
white-space: nowrap;
> span {
overflow: hidden;
text-overflow: ellipsis;
color: ${(props) => props.theme.text};
}
> svg {
color: ${(props) => props.theme.colors.text.muted};
}
&:hover,
&:hover > span {
color: ${(props) => props.theme.text};
}
&:hover {
border-color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
}
}
.upload-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease;
flex: 0 0 auto;
margin-left: auto;
&:hover {
color: ${(props) => props.theme.text};
}
}
`;
export const OverflowList = styled.div`
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px;
max-height: 260px;
overflow-y: auto;
min-width: 220px;
max-width: 360px;
.overflow-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 8px;
border-radius: 4px;
background: transparent;
font-size: 12px;
line-height: 1.2;
color: ${(props) => props.theme.text};
&:hover {
background: ${(props) => props.theme.requestTabs.icon.hoverBg};
}
}
.overflow-row-icon {
flex: 0 0 auto;
color: ${(props) => props.theme.colors.text.muted};
}
.overflow-row-name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.overflow-row-remove {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
cursor: pointer;
border-radius: 3px;
flex: 0 0 auto;
&:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
`;
export default Wrapper;

View File

@@ -0,0 +1,197 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import { IconUpload, IconX, IconFile, IconChevronDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import ToolHint from 'components/ToolHint';
import path, { normalizePath } from 'utils/common/path';
import Wrapper, { OverflowList } from './StyledWrapper';
const basename = (filePath) => (filePath ? path.basename(normalizePath(String(filePath))) : '');
const FileEntry = ({ filePath, toolhintId, editMode, onRemove, variant }) => {
const [overRemove, setOverRemove] = useState(false);
const isChip = variant === 'chip';
return (
<ToolHint
text={overRemove ? 'Remove file' : filePath}
toolhintId={toolhintId}
place={overRemove ? 'bottom-end' : 'bottom-start'}
positionStrategy="fixed"
tooltipStyle={{ maxWidth: '320px', whiteSpace: 'normal', wordBreak: 'break-all' }}
delayShow={overRemove ? 200 : 1000}
className={isChip ? 'file-chip' : 'overflow-row'}
dataTestId={isChip ? 'multipart-file-chip' : 'multipart-file-overflow-row'}
>
<IconFile size={14} stroke={1.5} className={isChip ? 'file-chip-icon' : 'overflow-row-icon'} />
<span className={isChip ? 'file-chip-name' : 'overflow-row-name'}>
{basename(filePath)}
</span>
{editMode && (
<button
type="button"
data-testid={isChip ? 'multipart-file-chip-remove' : 'multipart-file-overflow-remove'}
className={isChip ? 'file-chip-remove' : 'overflow-row-remove'}
onMouseEnter={() => setOverRemove(true)}
onMouseLeave={() => setOverRemove(false)}
onClick={(e) => {
e.stopPropagation();
onRemove(filePath);
}}
>
<IconX size={13} stroke={1.5} />
</button>
)}
</ToolHint>
);
};
// Keep in sync with the corresponding CSS values in StyledWrapper.js:
// MIN_CHIP_W ↔ .file-chip { min-width: 75px }
// CHIP_GAP ↔ .file-chips-row { gap: 4px }
const MIN_CHIP_W = 75;
const CHIP_GAP = 4;
const UPLOAD_RESERVE = 28;
const MORE_CHIP_RESERVE = 56;
const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) => {
const containerRef = useRef(null);
const tooltipPrefix = useRef(`mp-tip-${Math.random().toString(36).slice(2, 10)}`).current;
const [visibleCount, setVisibleCount] = useState(files.length);
const [summaryOpen, setSummaryOpen] = useState(false);
const [moreOpen, setMoreOpen] = useState(false);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
// Measure the td (column-width, stable) rather than the content-sized cell,
// which would feed back on visibleCount.
const td = container.closest('td') || container.parentElement;
if (!td) return;
const compute = () => {
const tdStyle = window.getComputedStyle(td);
const padX = parseFloat(tdStyle.paddingLeft) + parseFloat(tdStyle.paddingRight);
const total = td.clientWidth - padX;
if (files.length === 0) {
setVisibleCount(0);
return;
}
const allAtMin = files.length * MIN_CHIP_W + Math.max(0, files.length - 1) * CHIP_GAP;
if (allAtMin + UPLOAD_RESERVE <= total) {
setVisibleCount(files.length);
return;
}
const available = total - UPLOAD_RESERVE - MORE_CHIP_RESERVE;
const n = Math.max(0, Math.floor((available + CHIP_GAP) / (MIN_CHIP_W + CHIP_GAP)));
setVisibleCount(n);
};
compute();
const ro = new ResizeObserver(compute);
ro.observe(td);
return () => ro.disconnect();
}, [files]);
const visible = files.slice(0, visibleCount);
const overflow = files.slice(visibleCount);
const collapsed = visibleCount === 0 && files.length > 0;
const renderChip = (filePath, idx) => (
<FileEntry
key={`${filePath}-${idx}`}
variant="chip"
filePath={filePath}
toolhintId={`${tooltipPrefix}-chip-${idx}`}
editMode={editMode}
onRemove={onRemove}
/>
);
const renderOverflowList = (list) => (
<OverflowList>
{list.map((p, i) => (
<FileEntry
key={`o-${p}-${i}`}
variant="overflow"
filePath={p}
toolhintId={`${tooltipPrefix}-overflow-${i}`}
editMode={editMode}
onRemove={onRemove}
/>
))}
</OverflowList>
);
return (
<Wrapper className="file-value-cell" ref={containerRef}>
{collapsed ? (
<>
<Dropdown
placement="bottom-start"
appendTo={() => document.body}
onMount={() => setSummaryOpen(true)}
onHidden={() => setSummaryOpen(false)}
icon={(
<button
type="button"
data-testid="multipart-file-summary"
className="file-summary-chip"
onClick={(e) => e.stopPropagation()}
title={`${files.length} file${files.length > 1 ? 's' : ''}`}
>
<IconFile size={14} stroke={1.5} className="file-chip-icon" />
<span>{files.length} file{files.length > 1 ? 's' : ''}</span>
<IconChevronDown size={14} stroke={1.5} />
</button>
)}
>
{summaryOpen ? renderOverflowList(files) : null}
</Dropdown>
</>
) : (
<>
<div className="file-chips-row">
{visible.map((p, i) => renderChip(p, i))}
</div>
{overflow.length > 0 && (
<Dropdown
placement="bottom-end"
appendTo={() => document.body}
onMount={() => setMoreOpen(true)}
onHidden={() => setMoreOpen(false)}
icon={(
<button
type="button"
data-testid="multipart-file-more"
className="file-more-chip"
onClick={(e) => e.stopPropagation()}
title={`${overflow.length} more file${overflow.length > 1 ? 's' : ''}`}
>
+{overflow.length} more
</button>
)}
>
{moreOpen ? renderOverflowList(overflow) : null}
</Dropdown>
)}
</>
)}
{editMode && (
<button
type="button"
data-testid="multipart-file-upload"
className="upload-btn ml-1"
onClick={onAdd}
title="Add files"
>
<IconUpload size={16} />
</button>
)}
</Wrapper>
);
};
export default MultipartFileChipsCell;

View File

@@ -96,7 +96,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
className="url-input file-pick-btn"
onClick={() => fileInputRef.current?.click()}
>
{sourceUrl ? sourceUrl.split(/[\\/]/).pop() : 'Choose file...'}
{sourceUrl ? sourceUrl.split(/[\\/]/).pop() : 'Select File'}
</button>
</>
)}

View File

@@ -101,7 +101,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
className="settings-input file-pick-btn"
onClick={() => fileInputRef.current?.click()}
>
{filePath ? filePath.split(/[\\/]/).pop() : 'Choose file...'}
{filePath ? filePath.split(/[\\/]/).pop() : 'Select File'}
</button>
</>
)}

View File

@@ -687,7 +687,7 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.colors.text.muted};
&.active {
background: ${(props) => props.theme.colors.text.green};
background: ${(props) => props.theme.button2.color.primary.bg};
}
.toggle-knob {
@@ -724,9 +724,9 @@ const StyledWrapper = styled.div`
transition: all 0.15s;
&.active {
border-color: ${(props) => props.theme.button2.color.primary.border};
background: ${(props) => props.theme.button2.color.primary.bg};
color: ${(props) => props.theme.button2.color.primary.text};
border-color: ${(props) => props.theme.accents.primary};
background: ${(props) => rgba(props.theme.accents.primary, 0.07)};
color: ${(props) => props.theme.accents.primary};
}
}
}

View File

@@ -93,7 +93,7 @@ const useOpenAPISync = (collection) => {
uid: itemUid,
collectionUid: collection.uid,
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
type: 'request'
type: item?.type ?? 'request'
}));
}
};

View File

@@ -0,0 +1,334 @@
import { useEffect, useRef, useState } from 'react';
import {
IconAlertCircle,
IconBolt,
IconCheck,
IconChevronDown,
IconEye,
IconEyeOff,
IconLoader2,
IconPencil,
IconTrash,
IconX
} from '@tabler/icons';
import toast from 'react-hot-toast';
import { clearAiApiKey, getAiApiKey, setAiApiKey, testAiProvider } from 'utils/ai';
const OpenAiLogo = (props) => (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.8956zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
</svg>
);
const AnthropicLogo = (props) => (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
<path d="M17.304 3.541h-3.672l6.696 16.918h3.672l-6.696-16.918Zm-10.608 0L0 20.459h3.744l1.368-3.584h6.624l1.368 3.584h3.744L10.152 3.54H6.696Zm.432 10.418 2.208-5.784 2.208 5.784H7.128Z" />
</svg>
);
const PROVIDER_LOGOS = {
openai: OpenAiLogo,
anthropic: AnthropicLogo
};
const stopBubble = (e) => e.stopPropagation();
const ProviderCard = ({
provider,
providerEnabled,
providerToggle,
models,
isModelEnabled,
onToggleModel,
onStatusChange
}) => {
const Logo = PROVIDER_LOGOS[provider.id];
const [expanded, setExpanded] = useState(false);
const [keyDraft, setKeyDraft] = useState('');
const [editing, setEditing] = useState(false);
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [feedback, setFeedback] = useState(null);
const prev = useRef({ enabled: providerEnabled });
useEffect(() => {
const was = prev.current;
if (!was.enabled && providerEnabled) {
setExpanded(true);
} else if (was.enabled && !providerEnabled) {
setExpanded(false);
}
prev.current = { enabled: providerEnabled };
}, [providerEnabled]);
const isEditing = editing || !provider.configured;
const handleSave = async () => {
const trimmed = keyDraft.trim();
if (!trimmed) return;
setSaving(true);
setFeedback(null);
try {
const status = await setAiApiKey({ providerId: provider.id, apiKey: trimmed });
onStatusChange?.(status);
setKeyDraft('');
setShowKey(false);
setEditing(false);
setFeedback({ type: 'success', message: 'API key saved' });
} catch (err) {
setFeedback({ type: 'error', message: err.message || 'Failed to save API key' });
} finally {
setSaving(false);
}
};
const handleClear = async () => {
setFeedback(null);
try {
const status = await clearAiApiKey({ providerId: provider.id });
onStatusChange?.(status);
setEditing(false);
setKeyDraft('');
toast.success(`${provider.label} API key removed`);
} catch (err) {
toast.error(err.message || 'Failed to clear API key');
}
};
const handleTest = async () => {
setTesting(true);
setFeedback(null);
try {
const result = await testAiProvider({ providerId: provider.id });
if (result.ok) {
setFeedback({ type: 'success', message: 'Connection successful' });
} else {
setFeedback({ type: 'error', message: result.error || 'Connection failed' });
}
} catch (err) {
setFeedback({ type: 'error', message: err.message || 'Connection failed' });
} finally {
setTesting(false);
}
};
const handleCancelEdit = () => {
setEditing(false);
setKeyDraft('');
setShowKey(false);
setFeedback(null);
};
const handleStartEdit = async () => {
setEditing(true);
setFeedback(null);
try {
const current = await getAiApiKey({ providerId: provider.id });
setKeyDraft(current || '');
} catch (err) {
// If we can't fetch it (decrypt failure etc.), leave the field empty.
setKeyDraft('');
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (keyDraft.trim() && !saving) handleSave();
} else if (e.key === 'Escape' && provider.configured) {
e.preventDefault();
handleCancelEdit();
}
};
const enabledModelsCount = models.filter((m) => isModelEnabled(m.id)).length;
return (
<div className={`provider-row ${expanded ? 'expanded' : ''}`} data-testid={`ai-provider-${provider.id}`}>
<div
className="provider-header flex items-center justify-between gap-3 px-3 py-2.5 cursor-pointer select-none"
onClick={() => setExpanded(!expanded)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpanded(!expanded);
}
}}
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
{Logo ? <Logo className="provider-logo w-[18px] h-[18px] flex-shrink-0" /> : null}
<span className="font-semibold text-[12.5px]">{provider.label}</span>
</div>
<div className="flex items-center gap-2.5 flex-shrink-0">
<span className={`provider-status inline-flex items-center gap-1.5 text-[11px] ${provider.configured ? 'configured' : ''}`}>
<span className={`status-dot w-[7px] h-[7px] rounded-full ${provider.configured ? 'configured' : ''}`} />
{provider.configured
? `${enabledModelsCount}/${models.length} models`
: 'Not configured'}
</span>
<span className="flex items-center" onClick={stopBubble}>
{providerToggle}
</span>
<span className={`chevron flex items-center ${expanded ? 'expanded' : ''}`}>
<IconChevronDown size={16} strokeWidth={1.5} />
</span>
</div>
</div>
<div className={`provider-body-wrapper ${expanded ? 'open' : ''}`}>
<div className="provider-body-inner">
<div className="provider-body flex flex-col gap-3.5 px-3 pt-3 pb-3">
{/* API key */}
<div>
<div className="key-section-label flex items-center justify-between gap-2 text-[11px] mb-1">
<span>API Key</span>
</div>
{!isEditing ? (
<div
className="key-display-row flex items-center justify-between gap-2 h-8 box-border pl-2.5 pr-0.5"
onClick={stopBubble}
>
<span className="key-display-mask text-xs"></span>
<div className="flex items-center gap-0.5">
<button
type="button"
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleTest}
disabled={testing || !providerEnabled}
title="Test connection"
aria-label="Test connection"
>
{testing ? <IconLoader2 size={15} className="spin" /> : <IconBolt size={15} />}
</button>
<button
type="button"
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleStartEdit}
title="Replace key"
aria-label="Replace key"
>
<IconPencil size={15} />
</button>
<button
type="button"
className="btn-icon danger w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleClear}
title="Remove key"
aria-label="Remove key"
>
<IconTrash size={15} />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-1.5" onClick={stopBubble}>
<div className="relative flex-1 flex items-center">
<input
id={`api-key-${provider.id}`}
type={showKey ? 'text' : 'password'}
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-8"
placeholder={provider.apiKeyPlaceholder}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={keyDraft}
onChange={(e) => setKeyDraft(e.target.value)}
onKeyDown={handleKeyDown}
onClick={stopBubble}
autoFocus
data-testid={`ai-provider-${provider.id}-key-input`}
/>
<button
type="button"
className="key-eye-btn absolute right-1 p-1 inline-flex items-center cursor-pointer"
onClick={() => setShowKey(!showKey)}
tabIndex={-1}
aria-label={showKey ? 'Hide API key' : 'Show API key'}
>
{showKey ? <IconEyeOff size={14} /> : <IconEye size={14} />}
</button>
</div>
<button
type="button"
className="btn-primary h-8 box-border px-3 text-xs font-medium inline-flex items-center justify-center gap-1 cursor-pointer"
disabled={saving || !keyDraft.trim()}
onClick={handleSave}
data-testid={`ai-provider-${provider.id}-save`}
>
{saving ? <IconLoader2 size={13} className="spin" /> : <IconCheck size={13} />}
Save
</button>
{provider.configured && (
<button
type="button"
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleCancelEdit}
title="Cancel"
>
<IconX size={15} />
</button>
)}
</div>
)}
{feedback && (
<div
className={`feedback flex items-center gap-1.5 text-[11px] px-2 py-1 mt-1.5 ${feedback.type}`}
role="status"
>
{feedback.type === 'success' ? <IconCheck size={12} /> : <IconAlertCircle size={12} />}
{feedback.message}
</div>
)}
</div>
{/* Models */}
{models.length > 0 && (
<div className="flex flex-col gap-1.5">
<div className="models-label-row flex items-center justify-between text-[11px]">
<span>Models</span>
{!provider.configured && (
<span className="keyless-hint flex items-center gap-1.5 text-[11px] py-1">
<IconAlertCircle size={12} />
Add an API key to enable
</span>
)}
</div>
<div className="grid grid-cols-2 gap-1">
{models.map((model) => {
const enabled = isModelEnabled(model.id);
const disabled = !provider.configured || !providerEnabled;
return (
<label
key={model.id}
className={`model-chip flex items-center gap-2 px-2.5 py-1.5 cursor-pointer select-none ${enabled && !disabled ? 'selected' : ''} ${disabled ? 'disabled' : ''}`}
onClick={stopBubble}
>
<input
type="checkbox"
className="cursor-pointer m-0"
checked={enabled}
disabled={disabled}
onChange={() => onToggleModel(model.id, !enabled)}
/>
<span className="text-xs">{model.label}</span>
</label>
);
})}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default ProviderCard;

View File

@@ -0,0 +1,243 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.ai-master {
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
background: ${(props) => props.theme.input.bg};
}
.ai-master-icon {
color: ${(props) => props.theme.colors.accent};
}
.ai-master-summary {
color: ${(props) => props.theme.colors.text.muted};
}
.ai-section-header {
color: ${(props) => props.theme.colors.text.muted};
}
.ai-empty-notice {
color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.input.bg};
border: 1px dashed ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
}
.provider-row {
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
background: ${(props) => props.theme.input.bg};
overflow: hidden;
transition: border-color 0.15s ease;
&.expanded {
border-color: ${(props) => props.theme.colors.accent}80;
}
}
.provider-header {
transition: background-color 0.15s ease;
&:hover {
background: ${(props) => props.theme.colors.accent}08;
}
}
.provider-logo {
color: ${(props) => props.theme.text};
}
.provider-status {
color: ${(props) => props.theme.colors.text.muted};
&.configured {
color: ${(props) => props.theme.colors.text.green};
}
}
.status-dot {
background: ${(props) => props.theme.input.border};
&.configured {
background: ${(props) => props.theme.colors.text.green};
box-shadow: 0 0 0 2px ${(props) => props.theme.colors.text.green}25;
}
}
.chevron {
color: ${(props) => props.theme.colors.text.muted};
transition: transform 0.2s ease;
&.expanded {
transform: rotate(180deg);
}
}
/* Smooth expand/collapse using grid-template-rows trick */
.provider-body-wrapper {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
&.open {
grid-template-rows: 1fr;
}
}
.provider-body-inner {
overflow: hidden;
min-height: 0;
}
.provider-body {
border-top: 1px solid ${(props) => props.theme.input.border};
}
.key-section-label {
color: ${(props) => props.theme.colors.text.muted};
}
.key-input {
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
border-radius: ${(props) => props.theme.border.radius.sm};
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.7;
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
}
}
.key-eye-btn {
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.colors.text.muted};
transition: background-color 0.15s ease, color 0.15s ease;
&:hover {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.colors.accent}10;
}
}
.key-display-row {
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.input.bg};
}
.key-display-mask {
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
color: ${(props) => props.theme.colors.text.muted};
letter-spacing: 1px;
}
.btn-primary {
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.colors.accent};
background: ${(props) => props.theme.colors.accent};
color: white;
transition: opacity 0.15s ease;
&:hover:not(:disabled) {
opacity: 0.88;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn-icon {
border-radius: ${(props) => props.theme.border.radius.sm};
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
transition: background-color 0.15s ease, color 0.15s ease;
&:hover:not(:disabled) {
background: ${(props) => props.theme.colors.accent}10;
color: ${(props) => props.theme.text};
}
&.danger:hover:not(:disabled) {
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.colors.bg.danger}15;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.feedback {
border-radius: ${(props) => props.theme.border.radius.sm};
&.success {
color: ${(props) => props.theme.colors.text.green};
background: ${(props) => props.theme.colors.text.green}10;
}
&.error {
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.colors.bg.danger}15;
}
}
.models-label-row {
color: ${(props) => props.theme.colors.text.muted};
}
.model-chip {
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid transparent;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover:not(.disabled) {
background: ${(props) => props.theme.colors.accent}08;
}
&.selected {
border-color: ${(props) => props.theme.input.border};
background: ${(props) => props.theme.colors.accent}06;
}
&.disabled {
opacity: 0.45;
cursor: not-allowed;
input,
label {
cursor: not-allowed;
}
}
}
.keyless-hint {
color: ${(props) => props.theme.colors.text.muted};
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 1s linear infinite;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,202 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import get from 'lodash/get';
import debounce from 'lodash/debounce';
import { useFormik } from 'formik';
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import { IconStars } from '@tabler/icons';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import ToggleSwitch from 'components/ToggleSwitch';
import { getAiStatus } from 'utils/ai';
import ProviderCard from './ProviderCard';
import StyledWrapper from './StyledWrapper';
const aiPreferencesSchema = Yup.object().shape({
enabled: Yup.boolean(),
providers: Yup.object(),
models: Yup.object(),
defaultModel: Yup.string().max(200).nullable()
});
const AI = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const [status, setStatus] = useState(null);
const [statusError, setStatusError] = useState(null);
const refreshStatus = useCallback(async () => {
try {
const next = await getAiStatus();
setStatus(next);
setStatusError(null);
} catch (err) {
setStatusError(err.message || 'Failed to load AI status');
}
}, []);
useEffect(() => {
refreshStatus();
}, [refreshStatus]);
const providerIds = status ? Object.keys(status.providers) : [];
const formik = useFormik({
enableReinitialize: true,
initialValues: {
enabled: get(preferences, 'ai.enabled', false),
providers: providerIds.reduce((acc, id) => {
acc[id] = { enabled: get(preferences, `ai.providers.${id}.enabled`, false) };
return acc;
}, {}),
models: get(preferences, 'ai.models', {}),
defaultModel: get(preferences, 'ai.defaultModel', '')
},
validationSchema: aiPreferencesSchema,
onSubmit: () => {}
});
const handleSave = useCallback(
(values) => {
dispatch(
savePreferences({
...preferences,
ai: {
enabled: values.enabled,
providers: values.providers,
models: values.models,
defaultModel: values.defaultModel || ''
}
})
).catch((err) => {
console.error('Failed to save AI preferences:', err);
toast.error('Failed to save AI preferences');
});
},
[dispatch, preferences]
);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((values) => {
aiPreferencesSchema
.validate(values, { abortEarly: true })
.then((validated) => handleSaveRef.current(validated))
.catch(() => {});
}, 400),
[]
);
useEffect(() => {
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
useEffect(() => () => debouncedSave.flush(), [debouncedSave]);
const modelsByProvider = useMemo(() => {
const grouped = {};
(status?.models || []).forEach((model) => {
if (!grouped[model.provider]) grouped[model.provider] = [];
grouped[model.provider].push(model);
});
return grouped;
}, [status]);
const isModelEnabled = (modelId) => get(formik.values, `models.${modelId}.enabled`, true);
const handleToggleModel = (modelId, next) => {
formik.setFieldValue(`models.${modelId}.enabled`, next);
};
const summary = useMemo(() => {
if (!status || !formik.values.enabled) return 'Turn on to configure providers and models';
const usableProviders = Object.values(status.providers).filter(
(p) => p.configured && formik.values.providers?.[p.id]?.enabled
);
if (usableProviders.length === 0) return 'Add a provider to get started';
// Count models live from formik + current key status, not the electron-side
// snapshot which lags behind toggle changes during the save debounce window.
const totalEnabledModels = (status.models || []).filter((m) => {
if (!formik.values.providers?.[m.provider]?.enabled) return false;
if (!status.providers?.[m.provider]?.configured) return false;
return isModelEnabled(m.id);
}).length;
const plural = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`;
return `${plural(usableProviders.length, 'provider')} · ${plural(totalEnabledModels, 'model')} ready`;
}, [status, formik.values.enabled, formik.values.providers, formik.values.models]);
return (
<StyledWrapper className="w-full flex flex-col text-xs min-h-0 max-h-[calc(100%-30px)]">
<div className="section-header">AI</div>
<div className="ai-master flex items-center justify-between gap-4 px-3.5 py-3 mb-4">
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2 text-[13px] font-semibold">
<IconStars size={15} strokeWidth={1.75} className="ai-master-icon" />
<span>AI Features</span>
</div>
<span className="ai-master-summary text-[11px]">{summary}</span>
</div>
<ToggleSwitch
size="m"
isOn={formik.values.enabled}
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
/>
</div>
{statusError && (
<div className="ai-empty-notice px-3.5 py-3 text-xs" role="alert">
{statusError}
</div>
)}
{!formik.values.enabled && !statusError && (
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
</div>
)}
{formik.values.enabled && status && (
<>
<div className="ai-section-header text-[11px] font-medium uppercase tracking-wider mt-[18px] mb-2">
Providers
</div>
<div className="flex flex-col gap-1.5">
{providerIds.map((id) => {
const provider = status.providers[id];
const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
const providerToggle = (
<ToggleSwitch
size="s"
isOn={providerEnabled}
handleToggle={() =>
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
/>
);
return (
<ProviderCard
key={id}
provider={provider}
providerEnabled={providerEnabled}
providerToggle={providerToggle}
models={modelsByProvider[id] || []}
isModelEnabled={isModelEnabled}
onToggleModel={handleToggleModel}
onStatusChange={(next) => setStatus(next)}
/>
);
})}
</div>
</>
)}
</StyledWrapper>
);
};
export default AI;

View File

@@ -233,7 +233,7 @@ const General = () => {
disabled={formik.values.customCaCertificate.enabled ? false : true}
onClick={() => inputFileCaCertificateRef.current.click()}
>
select file
Select File
<input
id="caCertFilePath"
type="file"

View File

@@ -7,7 +7,7 @@ import StyledWrapper from '../StyledWrapper';
const SystemProxy = () => {
const dispatch = useDispatch();
const systemProxyVariables = useSelector((state) => state.app.systemProxyVariables);
const { source, http_proxy, https_proxy, no_proxy } = systemProxyVariables || {};
const { source, http_proxy, https_proxy, no_proxy, pac_url } = systemProxyVariables || {};
const [isFetching, setIsFetching] = useState(true);
const [error, setError] = useState(null);
@@ -85,6 +85,12 @@ const SystemProxy = () => {
</label>
<div className="system-proxy-value">{no_proxy || '-'}</div>
</div>
<div className="mb-1 flex items-center">
<label className="settings-label">
pac_url
</label>
<div className="system-proxy-value">{pac_url || '-'}</div>
</div>
</div>
<span
className="text-link cursor-pointer hover:underline default-collection-location-browse flex flex-row items-center"

View File

@@ -3,11 +3,11 @@ import { useFormik } from 'formik';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
import toast from 'react-hot-toast';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import { savePreferences, refreshPacCache } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { useDispatch, useSelector } from 'react-redux';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { IconEye, IconEyeOff, IconRefresh } from '@tabler/icons';
import { useState } from 'react';
import SystemProxy from './SystemProxy';
@@ -103,6 +103,12 @@ const ProxySettings = ({ close }) => {
[]
);
const handleRefreshPac = () => {
dispatch(refreshPacCache())
.then(() => toast.success('PAC cache refreshed'))
.catch(() => toast.error('Failed to refresh PAC cache'));
};
const [passwordVisible, setPasswordVisible] = useState(false);
const [proxyMode, setProxyMode] = useState(() => {
if (preferences.proxy.disabled) return 'off';
@@ -439,7 +445,7 @@ const ProxySettings = ({ close }) => {
>
{formik.values.pac.source
? decodeURIComponent(formik.values.pac.source.split('/').pop())
: 'Choose file...'}
: 'Select File'}
</button>
)}
{formik.touched.pac?.source && formik.errors.pac?.source ? (
@@ -451,6 +457,15 @@ const ProxySettings = ({ close }) => {
? 'Enter the URL to your PAC file'
: 'Supports .pac files for automatic proxy configuration'}
</p>
{formik.values.pac.source ? (
<span
className="text-link cursor-pointer hover:underline flex flex-row items-center w-fit mt-2"
onClick={handleRefreshPac}
>
<IconRefresh size={14} strokeWidth={1.5} className="mr-1" />
Refetch
</span>
) : null}
</div>
</>
) : null}

View File

@@ -10,7 +10,8 @@ import {
IconKeyboard,
IconZoomQuestion,
IconSquareLetterB,
IconDatabase
IconDatabase,
IconStars
} from '@tabler/icons';
import Support from './Support';
@@ -20,6 +21,7 @@ import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
import Beta from './Beta';
import AI from './AI';
import StyledWrapper from './StyledWrapper';
import Cache from './Cache/index';
@@ -64,6 +66,10 @@ const Preferences = () => {
return <Beta />;
}
case 'ai': {
return <AI />;
}
case 'support': {
return <Support />;
}
@@ -98,6 +104,10 @@ const Preferences = () => {
<IconKeyboard size={16} strokeWidth={1.5} />
Keybindings
</div>
<div className={getTabClassname('ai')} role="tab" onClick={() => setTab('ai')}>
<IconStars size={16} strokeWidth={1.5} />
AI
</div>
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
<IconDatabase size={16} strokeWidth={1.5} />
Cache

View File

@@ -25,7 +25,7 @@ const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end auth-type-label select-none">
<div ref={ref} data-testid="auth-placement-label" className="flex items-center justify-end auth-type-label select-none">
{humanizeRequestAPIKeyPlacement(apikeyAuth?.placement)}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
@@ -89,7 +89,7 @@ const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
</div>
<label className="block mb-1">Add To</label>
<div className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
<div data-testid="auth-placement-selector" className="inline-flex items-center cursor-pointer auth-placement-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"

View File

@@ -81,14 +81,15 @@ const AuthMode = ({ item, collection }) => {
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer auth-mode-selector">
<div className="inline-flex items-center cursor-pointer auth-mode-selector" data-testid="auth-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"
selectedItemId={authMode}
showTickMark={true}
data-testid="auth-mode-dropdown"
>
<div className="flex items-center justify-center auth-mode-label select-none">
<div className="flex items-center justify-center auth-mode-label select-none" data-testid="auth-mode-label">
{humanizeRequestAuthMode(authMode)} <IconCaretDown className="caret ml-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>

View File

@@ -256,7 +256,7 @@ const OAuth1 = ({ item = {}, collection, request, save, updateAuth }) => {
<button
className="flex items-center gap-1 oauth1-icon cursor-pointer text-link"
onClick={handleBrowse}
title="Select file"
title="Select File"
type="button"
>
<IconUpload size={14} />

View File

@@ -80,6 +80,7 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
{ id: 'implicit', label: 'Implicit', onClick: () => onGrantTypeChange('implicit') },
{ id: 'client_credentials', label: 'Client Credentials', onClick: () => onGrantTypeChange('client_credentials') }
]}
data-testid="grant-type-dropdown"
selectedItemId={oAuth?.grantType}
placement="bottom-end"
>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import get from 'lodash/get';
import AwsV4Auth from './AwsV4Auth';
import BearerAuth from './BearerAuth';
@@ -15,22 +15,11 @@ import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import OAuth2 from './OAuth2/index';
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
const getTreePathFromCollectionToItem = (collection, _item) => {
let path = [];
let item = findItemInCollection(collection, _item?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
import { getEffectiveAuthSource } from 'utils/auth';
const Auth = ({ item, collection }) => {
const dispatch = useDispatch();
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
// Create a request object to pass to the auth components
const request = item.draft
@@ -42,34 +31,10 @@ const Auth = ({ item, collection }) => {
return dispatch(saveRequest(item.uid, collection.uid));
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: i.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const inheritedSource = useMemo(
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
[authMode, item, collection]
);
const getAuthView = () => {
switch (authMode) {
@@ -104,12 +69,11 @@ const Auth = ({ item, collection }) => {
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();
return (
<>
<div className="flex flex-row w-full gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
<div>Auth inherited from {inheritedSource.name}: </div>
<div className="inherit-mode-text" data-testid="inherited-auth-mode">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
</div>
</>
);

View File

@@ -24,10 +24,12 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import Documentation from 'components/Documentation/index';
import useGraphqlSchema from '../GraphQLSchemaActions/useGraphqlSchema';
import { findEnvironmentInCollection } from 'utils/collections';
import { hasEffectiveAuth } from 'utils/auth';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import Settings from 'components/RequestPane/Settings';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import AuthMode from '../Auth/AuthMode/index';
import StatusDot from 'components/StatusDot';
const TAB_CONFIG = [
{ key: 'query', label: 'Query' },
@@ -172,7 +174,20 @@ const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handle
[dispatch, item.uid]
);
const allTabs = useMemo(() => TAB_CONFIG.map(({ key, label }) => ({ key, label })), []);
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
const hasAuth = useMemo(
() => hasEffectiveAuth(collection, item),
[item, itemAuthMode, collection]
);
const allTabs = useMemo(
() => TAB_CONFIG.map(({ key, label }) => ({
key,
label,
indicator: key === 'auth' && hasAuth ? <StatusDot dataTestId="auth" /> : null
})),
[hasAuth]
);
const handlePrettify = useCallback(() => {
if (queryEditorRef.current?.beautifyRequestBody) {

View File

@@ -39,7 +39,7 @@ const MessageToolbar = ({
</ToolHint>
<ToolHint text="Generate sample" toolhintId={`regenerate-msg-${index}`}>
<button onClick={onRegenerateMessage} className="toolbar-btn">
<button onClick={onRegenerateMessage} className="toolbar-btn" data-testid={`grpc-regenerate-message-${index}`}>
<IconRefresh size={16} strokeWidth={1.5} />
</button>
</ToolHint>

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import GrpcAuthMode from './GrpcAuthMode';
@@ -9,32 +9,32 @@ import OAuth2 from '../../Auth/OAuth2/index';
import WsseAuth from '../../Auth/WsseAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
import { getEffectiveAuthSource } from 'utils/auth';
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
// List of auth modes supported by gRPC
// Note: Only header-based auth modes work with gRPC
// Complex auth modes like AWS Sig v4, Digest, and NTLM require axios interceptors
// and cannot be supported in gRPC requests as of now
const supportedGrpcAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'wsse', 'none', 'inherit'];
import { AUTH_MODES_GRPC } from 'utils/common/constants';
const GrpcAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
const request = item.draft
? get(item, 'draft.request', {})
: get(item, 'request', {});
const inheritedSource = useMemo(
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
[authMode, item, collection]
);
const save = () => {
return saveRequest(item.uid, collection.uid);
};
// Reset to 'none' if current auth mode is not supported by gRPC
useEffect(() => {
if (authMode && !supportedGrpcAuthModes.includes(authMode)) {
if (authMode && !AUTH_MODES_GRPC.includes(authMode)) {
dispatch(
updateRequestAuthMode({
itemUid: item.uid,
@@ -45,35 +45,6 @@ const GrpcAuth = ({ item, collection }) => {
}
}, [authMode, collection.uid, dispatch, item.uid]);
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: i.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const getAuthView = () => {
switch (authMode) {
case 'none': {
@@ -95,15 +66,13 @@ const GrpcAuth = ({ item, collection }) => {
return <WsseAuth collection={collection} item={item} updateAuth={updateAuth} request={request} save={save} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();
// Only show inherited auth if it's one of the supported types
if (source && supportedGrpcAuthModes.includes(source.auth?.mode)) {
if (inheritedSource && AUTH_MODES_GRPC.includes(inheritedSource.auth?.mode)) {
return (
<>
<div className="flex flex-row w-full gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
<div>Auth inherited from {inheritedSource.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
</div>
</>
);

View File

@@ -12,6 +12,8 @@ import Documentation from 'components/Documentation/index';
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import StyledWrapper from './StyledWrapper';
import { hasEffectiveAuth } from 'utils/auth';
import { AUTH_MODES_GRPC } from 'utils/common/constants';
const GrpcRequestPane = ({ item, collection, handleRun }) => {
const dispatch = useDispatch();
@@ -53,8 +55,11 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
const body = getPropertyFromDraftOrRequest(item, 'request.body');
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
const hasAuth = useMemo(
() => hasEffectiveAuth(collection, item, AUTH_MODES_GRPC),
[item, itemAuthMode, collection]
);
const activeHeadersLength = headers.filter((header) => header.enabled).length;
const grpcMessagesCount = body?.grpc?.length || 0;
@@ -88,7 +93,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
{
key: 'auth',
label: 'Auth',
indicator: auth?.mode && auth.mode !== 'none' ? <StatusDot type="default" /> : null
indicator: hasAuth ? <StatusDot type="default" dataTestId="auth" /> : null
},
{
key: 'docs',
@@ -96,7 +101,7 @@ const GrpcRequestPane = ({ item, collection, handleRun }) => {
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
}
];
}, [grpcMessagesCount, isClientStreaming, activeHeadersLength, auth?.mode, docs]);
}, [grpcMessagesCount, isClientStreaming, activeHeadersLength, hasAuth, docs]);
// Initialize tab to 'body' if no tab is currently set
useEffect(() => {

View File

@@ -18,6 +18,7 @@ import StatusDot from 'components/StatusDot';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import AuthMode from '../Auth/AuthMode/index';
import { hasEffectiveAuth } from 'utils/auth';
const TAB_CONFIG = [
{ key: 'params', label: 'Params' },
@@ -54,7 +55,6 @@ const HttpRequestPane = ({ item, collection }) => {
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const getProperty = useCallback(
(key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])),
[item.draft, item]
@@ -86,6 +86,12 @@ const HttpRequestPane = ({ item, collection }) => {
[dispatch, item.uid]
);
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
const hasAuth = useMemo(
() => hasEffectiveAuth(collection, item),
[item, itemAuthMode, collection]
);
const indicators = useMemo(() => {
const hasScriptError = item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage;
const hasTestError = item.testScriptErrorMessage;
@@ -94,7 +100,7 @@ const HttpRequestPane = ({ item, collection }) => {
params: activeCounts.params > 0 ? <sup className="font-medium">{activeCounts.params}</sup> : null,
body: body.mode !== 'none' ? <StatusDot /> : null,
headers: activeCounts.headers > 0 ? <sup className="font-medium">{activeCounts.headers}</sup> : null,
auth: auth.mode !== 'none' ? <StatusDot /> : null,
auth: hasAuth ? <StatusDot dataTestId="auth" /> : null,
vars: activeCounts.vars > 0 ? <sup className="font-medium">{activeCounts.vars}</sup> : null,
script: (script.req || script.res) ? (hasScriptError ? <StatusDot type="error" /> : <StatusDot />) : null,
assert: activeCounts.assertions > 0 ? <sup className="font-medium">{activeCounts.assertions}</sup> : null,
@@ -102,7 +108,7 @@ const HttpRequestPane = ({ item, collection }) => {
docs: docs?.length > 0 ? <StatusDot /> : null,
settings: tags?.length > 0 ? <StatusDot /> : null
};
}, [activeCounts, body.mode, auth.mode, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
}, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
const allTabs = useMemo(
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),

View File

@@ -13,6 +13,7 @@ const Wrapper = styled.div`
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease;
flex: 0 0 auto;
&:hover {
color: ${(props) => props.theme.text};
@@ -23,15 +24,6 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
}
.file-value-cell {
width: 100%;
.file-name {
font-size: 12px;
color: ${(props) => props.theme.text};
}
}
.value-cell {
width: 100%;

View File

@@ -1,8 +1,9 @@
import React, { useCallback, useRef } from 'react';
import get from 'lodash/get';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconUpload, IconX, IconFile } from '@tabler/icons';
import { IconUpload } from '@tabler/icons';
import {
moveMultipartFormParam,
setMultipartFormParams
@@ -10,14 +11,18 @@ import {
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import path from 'utils/common/path';
import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
import { isWindowsOS } from 'utils/common/platform';
const fileBasename = (filePath) =>
filePath ? path.basename(normalizePath(String(filePath))) : '';
const MultipartFormParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -57,32 +62,53 @@ const MultipartFormParams = ({ item, collection }) => {
}, [dispatch, collection.uid, item.uid]);
const handleBrowseFiles = useCallback((row, onChange) => {
dispatch(browseFiles())
dispatch(browseFiles([], ['multiSelections']))
.then((filePaths) => {
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
const processedPaths = filePaths.map((filePath) => {
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
return path.relative(collectionDir, filePath);
}
return filePath;
return getRelativePathWithinBasePath(collection.pathname, filePath);
});
const currentParams = item.draft
? get(item, 'draft.request.body.multipartForm')
: get(item, 'request.body.multipartForm');
const existsInParams = (currentParams || []).some((p) => p.uid === row.uid);
const existingParam = (currentParams || []).find((p) => p.uid === row.uid);
const existingValue = existingParam && existingParam.type === 'file' && Array.isArray(existingParam.value)
? existingParam.value
: [];
const seen = new Set(existingValue);
const merged = [...existingValue];
const skipped = [];
for (const p of processedPaths) {
if (!seen.has(p)) {
seen.add(p);
merged.push(p);
} else {
skipped.push(p);
}
}
if (skipped.length === 1) {
toast(`"${fileBasename(skipped[0])}" is already added`);
} else if (skipped.length > 1) {
toast(`${skipped.length} files are already added — skipped`);
}
const autoContentType = getMultipartAutoContentType(merged);
let updatedParams;
if (existsInParams) {
if (existingParam) {
updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'file', value: processedPaths };
return { ...p, type: 'file', value: merged, contentType: autoContentType };
}
return p;
});
} else {
updatedParams = [
...(currentParams || []),
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: processedPaths, contentType: '' }
{ uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: merged, contentType: autoContentType }
];
}
handleParamsChange(updatedParams);
@@ -92,13 +118,21 @@ const MultipartFormParams = ({ item, collection }) => {
});
}, [dispatch, collection.pathname, item, handleParamsChange]);
const handleClearFile = useCallback((row) => {
const handleRemoveFile = useCallback((row, filePathToRemove) => {
const currentParams = params || [];
const target = currentParams.find((p) => p.uid === row.uid);
if (!target || target.type !== 'file') return;
const currentValue = Array.isArray(target.value)
? target.value
: (target.value ? [target.value] : []);
const nextValue = currentValue.filter((p) => p !== filePathToRemove);
const updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'text', value: '' };
if (p.uid !== row.uid) return p;
if (nextValue.length === 0) {
return { ...p, type: 'text', value: '', contentType: '' };
}
return p;
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
});
handleParamsChange(updatedParams);
}, [params, handleParamsChange]);
@@ -119,19 +153,12 @@ const MultipartFormParams = ({ item, collection }) => {
}
}, [params, handleParamsChange]);
const getFileName = (filePaths) => {
const getFileList = (filePaths) => {
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
return null;
return [];
}
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
const validPaths = paths.filter((v) => v != null && v !== '');
if (validPaths.length === 0) return null;
const separator = isWindowsOS() ? '\\' : '/';
if (validPaths.length === 1) {
return validPaths[0].split(separator).pop();
}
return `${validPaths.length} file(s)`;
return paths.filter((v) => v != null && v !== '');
};
const columns = [
@@ -148,29 +175,14 @@ const MultipartFormParams = ({ item, collection }) => {
placeholder: 'Value',
width: '35%',
render: ({ row, value, onChange }) => {
const isFile = row.type === 'file';
const fileName = isFile ? getFileName(value) : null;
if (fileName) {
const files = row.type === 'file' ? getFileList(value) : [];
if (files.length > 0) {
return (
<div className="flex items-center file-value-cell">
<IconFile size={16} className="text-muted mr-1" />
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
<SingleLineEditor
theme={storedTheme}
value={fileName}
readOnly={true}
collection={collection}
item={item}
/>
</div>
<button
className="clear-file-btn ml-1"
onClick={() => handleClearFile(row)}
title="Remove file"
>
<IconX size={16} />
</button>
</div>
<MultipartFileChipsCell
files={files}
onRemove={(filePath) => handleRemoveFile(row, filePath)}
onAdd={() => handleBrowseFiles(row, onChange)}
/>
);
}
@@ -190,9 +202,10 @@ const MultipartFormParams = ({ item, collection }) => {
/>
</div>
<button
data-testid="multipart-file-upload"
className="upload-btn ml-1"
onClick={() => handleBrowseFiles(row, onChange)}
title="Select file"
title="Select File"
>
<IconUpload size={16} />
</button>

View File

@@ -16,6 +16,7 @@ import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import onHasCompletion from './onHasCompletion';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupCodeMirrorResizeRefresh } from 'utils/codemirror/resize';
const CodeMirror = require('codemirror');
@@ -53,6 +54,16 @@ export default class QueryEditor extends React.Component {
}
componentDidMount() {
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs.
*/
const runShortcut = () => {};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
@@ -125,7 +136,9 @@ export default class QueryEditor extends React.Component {
}
},
'Cmd-F': 'findPersistent',
'Ctrl-F': 'findPersistent'
'Ctrl-F': 'findPersistent',
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut
}
}));
if (editor) {
@@ -137,6 +150,7 @@ export default class QueryEditor extends React.Component {
this.addOverlay();
setupLinkAware(editor);
this.cleanupResizeRefresh = setupCodeMirrorResizeRefresh(editor, this._node);
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
const cmInput = editor.getInputField();
@@ -180,6 +194,7 @@ export default class QueryEditor extends React.Component {
if (this.editor?._destroyLinkAware) {
this.editor._destroyLinkAware();
}
this.cleanupResizeRefresh?.();
this.editor.off('change', this._onEdit);
this.editor.off('keyup', this._onKeyUp);
this.editor.off('hasCompletion', this._onHasCompletion);

View File

@@ -1,8 +1,10 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useMemo, 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 AIAssist from 'components/AIAssist';
import { buildRequestContextFromItem } from 'utils/ai';
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -78,6 +80,8 @@ const Script = ({ item, collection }) => {
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
@@ -104,41 +108,57 @@ const Script = ({ item, collection }) => {
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onRequestScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onRequestScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
requestContext={requestContext}
onApply={onRequestScriptEdit}
/>
</div>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="script:post-response"
value={responseScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onResponseScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="script:post-response"
value={responseScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onResponseScriptEdit}
mode="javascript"
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
requestContext={requestContext}
onApply={onResponseScriptEdit}
/>
</div>
</TabsContent>
</Tabs>
</div>

View File

@@ -1,7 +1,9 @@
import React, { useRef } from 'react';
import React, { useMemo, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildRequestContextFromItem } from 'utils/ai';
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -29,8 +31,10 @@ const Tests = ({ item, collection }) => {
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
return (
<div data-testid="test-script-editor">
<div data-testid="test-script-editor" className="relative h-full">
<CodeEditor
ref={testsEditorRef}
collection={collection}
@@ -47,6 +51,7 @@ const Tests = ({ item, collection }) => {
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} onApply={onEdit} />
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React, { useEffect, useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch } from 'react-redux';
import BearerAuth from '../../Auth/BearerAuth';
@@ -6,16 +6,15 @@ import BasicAuth from '../../Auth/BasicAuth';
import ApiKeyAuth from '../../Auth/ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import { getTreePathFromCollectionToItem } from 'utils/collections/index';
import { getEffectiveAuthSource } from 'utils/auth';
import { updateRequestAuthMode, updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
const supportedAuthModes = ['basic', 'bearer', 'apikey', 'oauth2', 'none', 'inherit'];
import { AUTH_MODES_WS } from 'utils/common/constants';
const WSAuth = ({ item, collection }) => {
const dispatch = useDispatch();
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
const request = item.draft
? get(item, 'draft.request', {})
@@ -25,9 +24,14 @@ const WSAuth = ({ item, collection }) => {
return saveRequest(item.uid, collection.uid);
};
const inheritedSource = useMemo(
() => (authMode === 'inherit' ? getEffectiveAuthSource(collection, item) : null),
[authMode, item, collection]
);
// Reset to 'none' if current auth mode is not supported
useEffect(() => {
if (authMode && !supportedAuthModes.includes(authMode)) {
if (authMode && !AUTH_MODES_WS.includes(authMode)) {
dispatch(updateRequestAuthMode({
itemUid: item.uid,
collectionUid: collection.uid,
@@ -36,35 +40,6 @@ const WSAuth = ({ item, collection }) => {
}
}, [authMode, collection.uid, dispatch, item.uid]);
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionRoot = collection?.draft?.root || collection?.root || {};
const collectionAuth = get(collectionRoot, 'request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: i.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const getAuthView = () => {
switch (authMode) {
case 'none': {
@@ -91,26 +66,24 @@ const WSAuth = ({ item, collection }) => {
);
}
case 'inherit': {
const source = getEffectiveAuthSource();
// Check if inherited auth is OAuth1/OAuth2 - not supported for WebSockets
if (source?.auth?.mode === 'oauth1' || source?.auth?.mode === 'oauth2') {
if (inheritedSource?.auth?.mode === 'oauth1' || inheritedSource?.auth?.mode === 'oauth2') {
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
{source.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
{inheritedSource.auth.mode === 'oauth1' ? 'OAuth 1.0' : 'OAuth 2'} not <strong>yet</strong> supported by WebSockets. Using no auth instead.
</div>
</>
);
}
// Only show inherited auth if it's one of the supported types
if (source && supportedAuthModes.includes(source.auth?.mode)) {
if (inheritedSource && AUTH_MODES_WS.includes(inheritedSource.auth?.mode)) {
return (
<>
<div className="flex flex-row w-full gap-2">
<div> Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
<div> Auth inherited from {inheritedSource.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(inheritedSource.auth?.mode)}</div>
</div>
</>
);

View File

@@ -2,17 +2,26 @@ import React, { useMemo, useCallback, useRef } from 'react';
import Documentation from 'components/Documentation/index';
import RequestHeaders from 'components/RequestPane/RequestHeaders';
import StatusDot from 'components/StatusDot/index';
import { find } from 'lodash';
import ActionIcon from 'ui/ActionIcon';
import ToolHint from 'components/ToolHint/index';
import { IconPlus, IconWand } from '@tabler/icons';
import { find, get } from 'lodash';
import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { useDispatch, useSelector } from 'react-redux';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import { getPropertyFromDraftOrRequest } from 'utils/collections/index';
import { prettifyJsonString, uuid } from 'utils/common/index';
import xmlFormat from 'xml-formatter';
import toast from 'react-hot-toast';
import WsBody from '../WsBody/index';
import StyledWrapper from './StyledWrapper';
import WSAuth from './WSAuth';
import WSAuthMode from './WSAuth/WSAuthMode';
import WSSettingsPane from '../WSSettingsPane/index';
import { hasEffectiveAuth } from 'utils/auth';
import { AUTH_MODES_WS } from 'utils/common/constants';
const WSRequestPane = ({ item, collection, handleRun }) => {
const dispatch = useDispatch();
@@ -24,6 +33,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const requestPaneTab = focusedTab?.requestPaneTab;
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const selectTab = useCallback(
(tab) => {
dispatch(updateRequestPaneTab({
@@ -34,10 +45,70 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
[dispatch, item.uid]
);
const addNewMessage = useCallback(() => {
const currentMessages = Array.isArray(body?.ws)
? body.ws.map((msg) => ({ ...msg, selected: false }))
: [];
currentMessages.push({
uid: uuid(),
name: `message ${currentMessages.length + 1}`,
content: '{}',
type: 'json',
selected: true
});
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
}, [body, dispatch, item.uid, collection.uid]);
const onPrettifyAll = useCallback(() => {
const currentMessages = [...(body?.ws || [])];
let changed = false;
currentMessages.forEach((msg, i) => {
if (msg.type === 'json') {
try {
const pretty = prettifyJsonString(msg.content);
if (pretty !== msg.content) {
currentMessages[i] = { ...msg, content: pretty };
changed = true;
}
} catch (e) {
// skip invalid json
}
} else if (msg.type === 'xml') {
try {
const pretty = xmlFormat(msg.content, { collapseContent: true });
if (pretty !== msg.content) {
currentMessages[i] = { ...msg, content: pretty };
changed = true;
}
} catch (e) {
// skip invalid xml
}
}
});
if (changed) {
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
} else {
toast.error('Nothing to prettify');
}
}, [body, dispatch, item.uid, collection.uid]);
const headers = getPropertyFromDraftOrRequest(item, 'request.headers');
const docs = getPropertyFromDraftOrRequest(item, 'request.docs');
const auth = getPropertyFromDraftOrRequest(item, 'request.auth');
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
const hasAuth = useMemo(
() => hasEffectiveAuth(collection, item, AUTH_MODES_WS),
[item, itemAuthMode, collection]
);
const activeHeadersLength = headers.filter((header) => header.enabled).length;
const allTabs = useMemo(() => {
@@ -55,7 +126,7 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
{
key: 'auth',
label: 'Auth',
indicator: auth.mode !== 'none' ? <StatusDot type="default" /> : null
indicator: hasAuth ? <StatusDot type="default" dataTestId="auth" /> : null
},
{
key: 'settings',
@@ -68,7 +139,7 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
indicator: docs && docs.length > 0 ? <StatusDot type="default" /> : null
}
];
}, [activeHeadersLength, auth.mode, docs]);
}, [activeHeadersLength, hasAuth, docs]);
const tabPanel = useMemo(() => {
switch (requestPaneTab) {
@@ -77,9 +148,8 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
<WsBody
item={item}
collection={collection}
hideModeSelector={true}
hidePrettifyButton={true}
handleRun={handleRun}
onAddMessage={addNewMessage}
/>
);
}
@@ -99,17 +169,41 @@ const WSRequestPane = ({ item, collection, handleRun }) => {
return <div className="mt-4">404 | Not found</div>;
}
}
}, [requestPaneTab, item, collection, handleRun]);
}, [requestPaneTab, item, collection, handleRun, addNewMessage]);
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
const rightContent = requestPaneTab === 'auth' ? (
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
<WSAuthMode item={item} collection={collection} />
</div>
) : null;
let rightContent = null;
if (requestPaneTab === 'auth') {
rightContent = (
<div ref={rightContentRef} className="flex flex-grow justify-start items-center">
<WSAuthMode item={item} collection={collection} />
</div>
);
} else if (requestPaneTab === 'body') {
rightContent = (
<div ref={rightContentRef} className="flex items-center gap-2">
<ToolHint text="Prettify All" toolhintId="prettify-all-ws">
<ActionIcon
data-testid="ws-prettify-all"
onClick={onPrettifyAll}
>
<IconWand size={14} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Add Message" toolhintId="add-msg-ws">
<ActionIcon
data-testid="ws-add-message"
onClick={addNewMessage}
>
<IconPlus size={15} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
</div>
);
}
return (
<StyledWrapper className="flex flex-col h-full relative">

View File

@@ -1,72 +1,92 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
border-bottom: 1px solid ${(props) => props.theme.border.border0};
transition: opacity 0.15s ease;
&.single {
height: 100%;
.editor-container {
height: calc(100% - 32px);
}
&.disabled {
opacity: 0.45;
}
&:not(.single) {
min-height: 240px;
margin-bottom: 8px;
&.last {
margin-bottom: 0;
}
}
.message-toolbar {
.accordion-header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding: 4px 0px;
padding-top: 0px;
height: 32px;
flex-shrink: 0;
justify-content: space-between;
padding: 0.5rem 0;
cursor: pointer;
user-select: none;
.message-label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.subtext1};
margin-right: auto;
}
.toolbar-actions {
.accordion-left {
display: flex;
align-items: center;
gap: 2px;
gap: 0.375rem;
flex: 1;
min-width: 0;
color: ${(props) => props.theme.text};
.message-label {
font-size: ${(props) => props.theme.font.size.sm};
cursor: text;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.name-input {
font-size: ${(props) => props.theme.font.size.sm};
color: inherit;
background: ${(props) => props.theme.background.surface1};
border: none;
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
outline: none;
flex: 1;
}
}
.toolbar-btn {
.accordion-actions {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
transition: all 0.15s ease;
gap: 0.125rem;
&:hover {
background-color: ${(props) => props.theme.dropdown.hoverBg};
color: ${(props) => props.theme.text};
}
.hover-actions {
display: flex;
align-items: center;
gap: 0.125rem;
visibility: hidden;
opacity: 0;
transition: opacity 0.15s ease;
&.delete:hover {
color: ${(props) => props.theme.colors.text.danger};
.hover-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.25rem;
color: ${(props) => props.theme.text};
transition: all 0.15s ease;
&:hover {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}
&.delete:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
}
}
&:hover .hover-actions {
visibility: visible;
opacity: 1;
}
}
.editor-container {
flex: 1;
min-height: 0;
&:not(.disabled) .accordion-header .message-label {
color: ${(props) => props.theme.primary.text};
}
`;

View File

@@ -1,56 +1,117 @@
import { IconTrash, IconWand } from '@tabler/icons';
import { IconTrash, IconSend, IconChevronRight, IconChevronDown } from '@tabler/icons';
import CodeEditor from 'components/CodeEditor/index';
import ToolHint from 'components/ToolHint/index';
import { get } from 'lodash';
import invert from 'lodash/invert';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import React, { useState } from 'react';
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { autoDetectLang } from 'utils/codemirror/lang-detect';
import { toastError } from 'utils/common/error';
import { prettifyJsonString } from 'utils/common/index';
import xmlFormat from 'xml-formatter';
import { queueWsMessage, isWsConnectionActive, connectWS } from 'utils/network/index';
import { findCollectionByUid, findEnvironmentInCollection } from 'utils/collections/index';
import toast from 'react-hot-toast';
import WSRequestBodyMode from '../BodyMode/index';
import StyledWrapper from './StyledWrapper';
export const TYPE_BY_DECODER = {
base64: 'binary',
json: 'json',
xml: 'xml'
const codemirrorMode = {
text: 'application/text',
xml: 'application/xml',
json: 'application/ld+json'
};
export const DECODER_BY_TYPE = invert(TYPE_BY_DECODER);
// Maps stored type to display mode
const typeToMode = (type) => {
switch (type) {
case 'json': return 'json';
case 'xml': return 'xml';
default: return 'text';
}
};
export const SingleWSMessage = ({
message,
item,
collection,
index,
methodType,
handleRun,
canClientSendMultipleMessages,
isLast
isExpanded,
onToggle,
isNew,
onNewRendered,
isSelected,
onSelect
}) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const collections = useSelector((state) => state.collections.collections);
const { name, content, type } = message;
const [messageFormat, setMessageFormat] = useState(autoDetectLang(content));
const displayMode = typeToMode(type);
const displayName = name || `message ${index + 1}`;
const onUpdateMessageType = (type) => {
setMessageFormat(type);
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(displayName);
// Auto-focus the name input when this is a newly created message
useEffect(() => {
if (isNew) {
setIsEditing(true);
setEditValue(displayName);
onNewRendered();
}
}, [isNew]);
const saveName = (value) => {
const trimmed = value.trim() || `message ${index + 1}`;
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
...currentMessages[index],
type: DECODER_BY_TYPE[type]
name: trimmed
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
setIsEditing(false);
};
const handleNameKeyDown = (e) => {
if (e.key === 'Enter') {
saveName(editValue);
} else if (e.key === 'Escape') {
setEditValue(displayName);
setIsEditing(false);
}
};
const handleNameBlur = () => {
saveName(editValue);
};
const handleNameClick = useCallback((e) => {
e.stopPropagation();
setEditValue(displayName);
setIsEditing(true);
}, [displayName, onToggle]);
const fontSize = get(preferences, 'font.codeFontSize', 14);
const lineHeight = fontSize * 1.5;
const editorHeight = useMemo(() => {
const lineCount = (content || '').split('\n').length;
const lines = lineCount + 1;
return `${lines * lineHeight + 10}px`;
}, [content, lineHeight]);
const onUpdateMessageType = (newMode) => {
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
...currentMessages[index],
type: typeToMode(newMode)
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
@@ -60,13 +121,11 @@ export const SingleWSMessage = ({
const onEdit = (value) => {
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
name: name ? name : `message ${index + 1}`,
type: DECODER_BY_TYPE[messageFormat],
...currentMessages[index],
name: name || `message ${index + 1}`,
content: value
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
@@ -78,9 +137,7 @@ export const SingleWSMessage = ({
const onDeleteMessage = () => {
const currentMessages = [...(body.ws || [])];
currentMessages.splice(index, 1);
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
@@ -88,97 +145,112 @@ export const SingleWSMessage = ({
}));
};
let codeType = messageFormat;
if (TYPE_BY_DECODER[type]) {
codeType = TYPE_BY_DECODER[type];
}
const onSendMessage = useCallback(async () => {
try {
const col = findCollectionByUid(collections, collection.uid);
const environment = findEnvironmentInCollection(col, col?.activeEnvironmentUid);
const codemirrorMode = {
text: 'application/text',
xml: 'application/xml',
json: 'application/ld+json'
};
const onPrettify = () => {
if (codeType === 'json') {
try {
const prettyBodyJson = prettifyJsonString(content);
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
...currentMessages[index],
name: name ? name : `message ${index + 1}`,
content: prettyBodyJson
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
} catch (e) {
toastError(new Error('Unable to prettify. Invalid JSON format.'));
// Auto-connect if not already connected
const connectionStatus = await isWsConnectionActive(item.uid);
if (!connectionStatus.isActive) {
await connectWS(item, col, environment, col?.runtimeVariables, { connectOnly: true });
}
}
if (codeType === 'xml') {
try {
const prettyBodyXML = xmlFormat(content, { collapseContent: true });
const currentMessages = [...(body.ws || [])];
currentMessages[index] = {
...currentMessages[index],
name: name ? name : `message ${index + 1}`,
content: prettyBodyXML
};
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
collectionUid: collection.uid
}));
} catch (e) {
toastError(new Error('Unable to prettify. Invalid XML format.'));
const result = await queueWsMessage(item, col, environment, col?.runtimeVariables, index);
if (!result.success) {
toast.error(result.error || 'Failed to send message');
}
} catch (err) {
toast.error(err.message || 'Failed to send message');
}
};
const isSingleMessage = !canClientSendMultipleMessages || body.ws.length === 1;
}, [collections]);
return (
<StyledWrapper className={`message-container ${isSingleMessage ? 'single' : ''} ${isLast ? 'last' : ''}`}>
<div className="message-toolbar">
<span className="message-label">Message {index + 1}</span>
<div className="toolbar-actions">
<WSRequestBodyMode mode={messageFormat} onModeChange={onUpdateMessageType} />
<ToolHint text="Format" toolhintId={`prettify-msg-${index}`}>
<button onClick={onPrettify} className="toolbar-btn">
<IconWand size={16} strokeWidth={1.5} />
</button>
</ToolHint>
{index > 0 && (
<ToolHint text="Delete message" toolhintId={`delete-msg-${index}`}>
<button onClick={onDeleteMessage} className="toolbar-btn delete">
<IconTrash size={16} strokeWidth={1.5} />
</button>
</ToolHint>
<StyledWrapper
className={!isSelected ? 'disabled' : ''}
onMouseDownCapture={() => {
if (!isSelected) setTimeout(onSelect, 0);
}}
>
<div
className="accordion-header"
data-testid={`ws-message-header-${index}`}
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}}
>
<div className="accordion-left">
{isExpanded ? (
<IconChevronDown size={14} strokeWidth={2} />
) : (
<IconChevronRight size={14} strokeWidth={2} />
)}
{isEditing ? (
<input
ref={(node) => node?.focus()}
className="name-input"
data-testid={`ws-message-name-input-${index}`}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleNameKeyDown}
onBlur={handleNameBlur}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="message-label"
data-testid={`ws-message-label-${index}`}
onClick={(e) => {
e.preventDefault();
onToggle();
}}
onDoubleClick={handleNameClick}
>
{displayName}
</span>
)}
</div>
<div className="accordion-actions" onClick={(e) => e.stopPropagation()}>
<div className="hover-actions">
<ToolHint text="Send" toolhintId={`send-msg-${index}`}>
<button onClick={onSendMessage} className="hover-action-btn" data-testid={`ws-send-msg-${index}`}>
<IconSend size={14} strokeWidth={1.5} />
</button>
</ToolHint>
{(body.ws || []).length > 1 && (
<ToolHint text="Delete" toolhintId={`delete-msg-${index}`}>
<button onClick={onDeleteMessage} className="hover-action-btn delete" data-testid={`ws-delete-msg-${index}`}>
<IconTrash size={14} strokeWidth={1.5} />
</button>
</ToolHint>
)}
</div>
<WSRequestBodyMode mode={displayMode} onModeChange={onUpdateMessageType} />
</div>
</div>
<div className="editor-container">
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode={codemirrorMode[codeType] ?? 'text/plain'}
enableVariableHighlighting={true}
/>
</div>
{isExpanded && (
<div className="accordion-body" data-testid={`ws-message-body-${index}`} style={{ height: editorHeight }}>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={content}
onEdit={onEdit}
onRun={handleRun}
onSave={onSave}
mode={codemirrorMode[displayMode] ?? 'text/plain'}
enableVariableHighlighting={true}
/>
</div>
)}
</StyledWrapper>
);
};

View File

@@ -5,21 +5,10 @@ const Wrapper = styled.div`
flex-direction: column;
width: 100%;
height: 100%;
position: relative;
.messages-container {
flex: 1;
display: flex;
flex-direction: column;
&.single {
height: 100%;
}
&.multi {
overflow-y: auto;
padding-bottom: 48px;
}
overflow-y: auto;
}
.empty-state {
@@ -36,13 +25,20 @@ const Wrapper = styled.div`
}
}
.add-message-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background: ${(props) => props.theme.bg};
.add-message-link {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.875rem;
color: ${(props) => props.theme.primary.text};
cursor: pointer;
background: none;
border: none;
padding: 4px 0;
&:hover {
opacity: 0.8;
}
}
`;

View File

@@ -1,99 +1,124 @@
import { get } from 'lodash';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { IconPlus } from '@tabler/icons';
import React, { useEffect, useRef } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { SingleWSMessage } from './SingleWSMessage/index';
const WSBody = ({ item, collection, handleRun }) => {
const getSelectedIndex = (messages) => {
const idx = messages.findIndex((msg) => msg.selected);
return idx >= 0 ? idx : 0;
};
const WSBody = ({ item, collection, handleRun, onAddMessage }) => {
const dispatch = useDispatch();
const messagesContainerRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const messages = body?.ws || [];
const methodType = item.draft ? get(item, 'draft.request.methodType') : get(item, 'request.methodType');
const canClientSendMultipleMessages = false;
const selectedIndex = getSelectedIndex(messages);
// Auto-scroll to the latest message when messages are added
useEffect(() => {
if (messagesContainerRef.current && body?.ws?.length > 0) {
const container = messagesContainerRef.current;
container.scrollTop = container.scrollHeight;
}
}, [body?.ws?.length]);
const addNewMessage = () => {
const currentMessages = Array.isArray(body.ws) ? [...body.ws] : [];
currentMessages.push({
name: `message ${currentMessages.length + 1}`,
content: '{}'
});
// Expand the selected message by default (falls back to first)
const [expandedUids, setExpandedUids] = useState(() => {
const uid = messages[selectedIndex]?.uid || messages[0]?.uid;
return new Set(uid ? [uid] : []);
});
const [newMessageUid, setNewMessageUid] = useState(null);
const prevMessagesLengthRef = useRef(messages.length);
const setSelectedIndex = useCallback((index) => {
const currentMessages = [...(body?.ws || [])];
const updated = currentMessages.map((msg, i) => ({
...msg,
selected: i === index
}));
dispatch(updateRequestBody({
content: currentMessages,
content: updated,
itemUid: item.uid,
collectionUid: collection.uid
}));
};
}, [body, dispatch, item.uid, collection.uid]);
if (!body?.ws || !Array.isArray(body.ws)) {
const toggleMessage = useCallback((uid) => {
if (!uid) return;
setExpandedUids((prev) => {
const next = new Set(prev);
if (next.has(uid)) {
next.delete(uid);
} else {
next.add(uid);
}
return next;
});
}, []);
const handleSelect = useCallback((index) => {
if (index !== selectedIndex) {
setSelectedIndex(index);
}
}, [selectedIndex, setSelectedIndex]);
// React to new message being added (messages.length increased)
useEffect(() => {
if (messages.length > prevMessagesLengthRef.current) {
const newMsg = messages[messages.length - 1];
if (newMsg?.uid) {
setExpandedUids((prev) => new Set(prev).add(newMsg.uid));
setNewMessageUid(newMsg.uid);
setSelectedIndex(messages.length - 1);
}
}
prevMessagesLengthRef.current = messages.length;
}, [messages.length]);
const handleNewMessageRendered = useCallback(() => {
setNewMessageUid(null);
}, []);
// Auto-scroll to bottom when new message is added
useEffect(() => {
if (messagesContainerRef.current && messages.length > 0) {
const container = messagesContainerRef.current;
container.scrollTop = container.scrollHeight;
}
}, [messages.length]);
if (!messages.length) {
return (
<StyledWrapper>
<div className="empty-state">
<p>No WebSocket messages available</p>
<Button
onClick={addNewMessage}
variant="filled"
color="secondary"
size="sm"
icon={<IconPlus size={14} strokeWidth={1.5} />}
>
Add Message
</Button>
<button className="add-message-link" data-testid="ws-add-message" onClick={onAddMessage}>
<IconPlus size={14} strokeWidth={1.5} />
<span>Add message</span>
</button>
</div>
</StyledWrapper>
);
}
const messagesToShow = body.ws.filter((_, index) => canClientSendMultipleMessages || index === 0);
return (
<StyledWrapper>
<div
ref={messagesContainerRef}
className={`messages-container ${canClientSendMultipleMessages && messagesToShow.length > 1 ? 'multi' : 'single'}`}
>
{messagesToShow.map((message, index) => (
<div ref={messagesContainerRef} className="messages-container">
{messages.map((message, index) => (
<SingleWSMessage
key={index}
key={message.uid}
id={`ws-message-${message.uid}`}
message={message}
item={item}
collection={collection}
index={index}
methodType={methodType}
handleRun={handleRun}
canClientSendMultipleMessages={canClientSendMultipleMessages}
isLast={index === messagesToShow.length - 1}
isExpanded={expandedUids.has(message.uid)}
onToggle={() => toggleMessage(message.uid)}
isNew={newMessageUid === message.uid}
onNewRendered={handleNewMessageRendered}
isSelected={selectedIndex === index}
onSelect={() => handleSelect(index)}
/>
))}
</div>
{canClientSendMultipleMessages && (
<div className="add-message-footer">
<Button
onClick={addNewMessage}
variant="filled"
color="secondary"
size="sm"
fullWidth
icon={<IconPlus size={14} strokeWidth={1.5} />}
>
Add Message
</Button>
</div>
)}
</StyledWrapper>
);
};

View File

@@ -0,0 +1,79 @@
import React from 'react';
import { IconAlertTriangle } from '@tabler/icons';
import { useDispatch, useSelector } from 'react-redux';
import find from 'lodash/find';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { NON_CLOSABLE_TAB_TYPES } from 'providers/ReduxStore/slices/tabs';
import Button from 'ui/Button';
import { useTheme } from 'providers/Theme';
class TabPanelErrorBoundaryInner extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('[TabPanelErrorBoundary] Unexpected render error:', error, errorInfo);
}
render() {
const { theme } = this.props;
if (this.state.hasError) {
const { isClosable, onClose } = this.props;
const errorMessage = this.state.error?.message;
return (
<div className="h-full flex flex-col items-center justify-center gap-3 px-6 text-center">
<IconAlertTriangle size={36} strokeWidth={1.5} style={{ color: theme?.status?.warning?.text }} />
<h2 className="text-lg font-medium">Something went wrong</h2>
{isClosable ? (
<p className="text-sm opacity-70 max-w-md">
This tab encountered an unexpected error. Close it and try reopening the request. If the
error repeats, the request file may be corrupt.
</p>
) : (
<p className="text-sm opacity-70 max-w-md">
This panel encountered an unexpected error. Restart Bruno to recover.
</p>
)}
{errorMessage && (
<p className="text-xs font-mono opacity-50 max-w-md break-all">{errorMessage}</p>
)}
{isClosable && (
<Button size="md" data-testid="tab-panel-error-boundary-close-tab" color="primary" onClick={onClose}>
Close Tab
</Button>
)}
</div>
);
}
return this.props.children;
}
}
const TabPanelErrorBoundary = ({ tabUid, children }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === tabUid);
const isClosable = !focusedTab || !NON_CLOSABLE_TAB_TYPES.includes(focusedTab.type);
const { theme } = useTheme();
const handleClose = () => {
dispatch(closeTabs({ tabUids: [tabUid] }));
};
return (
<TabPanelErrorBoundaryInner isClosable={isClosable} onClose={handleClose} theme={theme}>
{children}
</TabPanelErrorBoundaryInner>
);
};
export default TabPanelErrorBoundary;

View File

@@ -43,6 +43,7 @@ import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironment
import OpenAPISyncTab from 'components/OpenAPISyncTab';
import OpenAPISpecTab from 'components/OpenAPISpecTab';
import CollapsedPanelIndicator from './CollapsedPanelIndicator';
import { IconLoader2 } from '@tabler/icons';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 490;
@@ -299,7 +300,12 @@ const RequestTabPanel = () => {
}
if (!activeTabUid || !focusedTab) {
return <div className="pb-4 px-4">Loading...</div>;
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted">
<IconLoader2 className="animate-spin" size={24} strokeWidth={1.5} />
<span>Loading...</span>
</div>
);
}
if (focusedTab.type === 'global-environment-settings') {
@@ -335,6 +341,9 @@ const RequestTabPanel = () => {
let example = null;
if (item?.examples) {
example = item.examples.find((ex) => ex.uid === focusedTab.uid);
if (!example && typeof focusedTab.exampleIndex === 'number' && focusedTab.exampleIndex >= 0) {
example = item.examples[focusedTab.exampleIndex] || null;
}
if (!example && focusedTab.exampleName) {
example = item.examples.find((ex) => ex.name === focusedTab.exampleName);
}
@@ -387,7 +396,7 @@ const RequestTabPanel = () => {
if (folder) {
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<FolderSettings collection={collection} folder={folder} />;
<FolderSettings collection={collection} folder={folder} />
</ScopedPersistenceProvider>
);
}

View File

@@ -26,11 +26,15 @@ const ExampleTab = ({ tab, collection }) => {
if (!item?.examples) return null;
const byUid = item.examples.find((ex) => ex.uid === tab.uid);
if (byUid) return byUid;
if (typeof tab.exampleIndex === 'number' && tab.exampleIndex >= 0) {
const byIndex = item.examples[tab.exampleIndex];
if (byIndex) return byIndex;
}
if (tab.exampleName) {
return item.examples.find((ex) => ex.name === tab.exampleName);
}
return null;
}, [item?.examples, tab.uid, tab.exampleName]);
}, [item?.examples, tab.uid, tab.exampleIndex, tab.exampleName]);
const hasChanges = useMemo(() => hasExampleChanges(item, example?.uid), [item, example?.uid]);

View File

@@ -259,7 +259,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
} else if (tab.type === 'global-environment-settings') {
if (globalEnvironmentDraft) {
const { environmentUid, variables } = globalEnvironmentDraft;
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
if (environmentUid?.startsWith('dotenv:')) {
window.dispatchEvent(new Event('dotenv-save'));
} else {
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
}
}
} else if (tab.type === 'folder-settings') {
if (folder) {

View File

@@ -62,7 +62,7 @@ const Wrapper = styled.div`
tr {
position: relative;
&:hover .delete-button.edit-mode {
opacity: 1;
visibility: visible;
@@ -92,10 +92,6 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
}
.file-value-cell {
width: 100%;
}
.value-cell {
width: 100%;
@@ -114,7 +110,7 @@ const Wrapper = styled.div`
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
margin-left: 8px;
&:hover {
color: ${(props) => props.theme.colors.text.red};
}

View File

@@ -1,18 +1,22 @@
import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { IconUpload, IconX, IconFile } from '@tabler/icons';
import { IconUpload } from '@tabler/icons';
import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/slices/collections';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import mime from 'mime-types';
import path from 'utils/common/path';
import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
import EditableTable from 'components/EditableTable';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
import StyledWrapper from './StyledWrapper';
import { isWindowsOS } from 'utils/common/platform';
const fileBasename = (filePath) =>
filePath ? path.basename(normalizePath(String(filePath))) : '';
const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, editMode = false }) => {
const dispatch = useDispatch();
@@ -48,50 +52,59 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
const handleBrowseFiles = useCallback((row, onChange) => {
if (!editMode) return;
dispatch(browseFiles())
dispatch(browseFiles([], ['multiSelections']))
.then((filePaths) => {
if (!Array.isArray(filePaths) || filePaths.length === 0) return;
const processedPaths = filePaths.map((filePath) => {
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
return path.relative(collectionDir, filePath);
}
return filePath;
return getRelativePathWithinBasePath(collection.pathname, filePath);
});
const currentParams = params || [];
const existingParam = currentParams.find((p) => p.uid === row.uid);
const existingValue = existingParam && existingParam.type === 'file' && Array.isArray(existingParam.value)
? existingParam.value
: [];
const seen = new Set(existingValue);
const merged = [...existingValue];
const skipped = [];
for (const p of processedPaths) {
if (!seen.has(p)) {
seen.add(p);
merged.push(p);
} else {
skipped.push(p);
}
}
if (skipped.length === 1) {
toast(`"${fileBasename(skipped[0])}" is already added`);
} else if (skipped.length > 1) {
toast(`${skipped.length} files are already added — skipped`);
}
const autoContentType = getMultipartAutoContentType(merged);
let updatedParams;
if (existingParam) {
// Update existing param
updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
const updated = { ...p, type: 'file', value: processedPaths };
// Auto-detect content type from first file
if (processedPaths.length > 0) {
const contentType = mime.contentType(path.extname(processedPaths[0]));
updated.contentType = contentType || '';
}
return updated;
return { ...p, type: 'file', value: merged, contentType: autoContentType };
}
return p;
});
} else {
// Add new param (from EditableTable's empty row)
const newParam = {
uid: row.uid,
name: row.name || '',
type: 'file',
value: processedPaths,
contentType: '',
enabled: true
};
// Auto-detect content type from first file
if (processedPaths.length > 0) {
const contentType = mime.contentType(path.extname(processedPaths[0]));
newParam.contentType = contentType || '';
}
updatedParams = [...currentParams, newParam];
updatedParams = [
...currentParams,
{
uid: row.uid,
name: row.name || '',
type: 'file',
value: merged,
contentType: autoContentType,
enabled: true
}
];
}
handleParamsChange(updatedParams);
@@ -101,21 +114,24 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
});
}, [editMode, dispatch, collection.pathname, params, handleParamsChange]);
const handleClearFile = useCallback((row) => {
const handleRemoveFile = useCallback((row, filePathToRemove) => {
if (!editMode) return;
const currentParams = params || [];
const existingParam = currentParams.find((p) => p.uid === row.uid);
const target = currentParams.find((p) => p.uid === row.uid);
if (!target || target.type !== 'file') return;
const currentValue = Array.isArray(target.value)
? target.value
: (target.value ? [target.value] : []);
const nextValue = currentValue.filter((p) => p !== filePathToRemove);
if (existingParam) {
const updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
return { ...p, type: 'text', value: '' };
}
return p;
});
handleParamsChange(updatedParams);
}
const updatedParams = currentParams.map((p) => {
if (p.uid !== row.uid) return p;
if (nextValue.length === 0) {
return { ...p, type: 'text', value: '', contentType: '' };
}
return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
});
handleParamsChange(updatedParams);
}, [editMode, params, handleParamsChange]);
const handleValueChange = useCallback((row, newValue, onChange) => {
@@ -151,19 +167,12 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
}));
}, [editMode, dispatch, item.uid, collection.uid, exampleUid, params]);
const getFileName = (filePaths) => {
const getFileList = (filePaths) => {
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
return null;
return [];
}
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
const validPaths = paths.filter((v) => v != null && v !== '');
if (validPaths.length === 0) return null;
const separator = isWindowsOS() ? '\\' : '/';
if (validPaths.length === 1) {
return validPaths[0].split(separator).pop();
}
return `${validPaths.length} file(s)`;
return paths.filter((v) => v != null && v !== '');
};
const columns = [
@@ -182,29 +191,15 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
width: '40%',
readOnly: !editMode,
render: ({ row, value, onChange }) => {
const isFile = row.type === 'file';
const fileName = isFile ? getFileName(value) : null;
if (fileName) {
const fileList = row.type === 'file' ? getFileList(value) : [];
if (fileList.length > 0) {
return (
<div className="flex items-center file-value-cell">
<IconFile size={16} className="text-muted mr-1" />
<div className="file-name flex-1 truncate" title={Array.isArray(value) ? value.join(', ') : value}>
<SingleLineEditor
theme={storedTheme}
value={fileName}
readOnly={true}
collection={collection}
item={item}
/>
</div>
<button
className="clear-file-btn ml-1"
onClick={() => handleClearFile(row)}
title="Remove file"
>
<IconX size={16} />
</button>
</div>
<MultipartFileChipsCell
files={fileList}
onRemove={(filePath) => handleRemoveFile(row, filePath)}
onAdd={() => handleBrowseFiles(row, onChange)}
editMode={editMode}
/>
);
}
@@ -227,7 +222,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
<button
className="upload-btn ml-1"
onClick={() => handleBrowseFiles(row, onChange)}
title="Select file"
title="Select File"
>
<IconUpload size={16} />
</button>

View File

@@ -1,68 +1,60 @@
import React, { useMemo } from 'react';
import forOwn from 'lodash/forOwn';
import StyledWrapper from './StyledWrapper';
import TimelineItem from '../Timeline/TimelineItem';
const RunnerTimeline = ({ request = {}, response = {}, item, collection }) => {
const requestHeaders = [];
// Reads from the runner item only, never collection.timeline, so a later
// single-request invocation of the same item can't bleed into this view.
const entries = useMemo(() => {
const mainTimestamp = request?.timestamp ?? response?.timestamp ?? Date.now();
forOwn(request.headers, (value, key) => {
requestHeaders.push({
name: key,
value
const oauth = (item?.oauth2DebugEntries || []).flatMap((event) => {
const debugInfo = event.debugInfo || [];
return [...debugInfo].reverse().map((sub, i) => ({
kind: 'oauth2',
timestamp: mainTimestamp - 1 - i,
request: sub?.request,
response: sub?.response
}));
});
});
const oauth2Events = useMemo(
() =>
collection?.timeline?.filter(
(event) => event.type === 'oauth2' && event.itemUid === item.uid
) || [],
[collection?.timeline, item.uid]
);
const scripted = (item?.scriptedRequestEntries || []).map((e) => ({
kind: 'scripted',
timestamp: e.timestamp,
request: e.data?.request,
response: e.data?.response,
source: e.source,
scope: e.scope,
phase: e.phase
}));
const main = {
kind: 'main',
timestamp: mainTimestamp,
request,
response
};
return [main, ...oauth, ...scripted].sort((a, b) => b.timestamp - a.timestamp);
}, [item?.oauth2DebugEntries, item?.scriptedRequestEntries, request, response]);
return (
<StyledWrapper className="pb-4 w-full">
{/* Show the main request/response timeline item */}
<TimelineItem
request={request}
response={response}
item={item}
collection={collection}
hideTimestamp={true}
/>
{oauth2Events.map((event, index) => {
const { data, timestamp } = event;
const { debugInfo } = data;
return (
<div key={`oauth2-${index}`} className="timeline-event mt-4">
<div className="timeline-event-header cursor-pointer flex items-center">
<div className="flex items-center">
<span className="font-bold">OAuth2.0 Calls</span>
</div>
</div>
<div className="mt-2">
{debugInfo && debugInfo.length > 0 ? (
debugInfo.map((data, idx) => (
<div key={idx} className="ml-4">
<TimelineItem
timestamp={timestamp}
request={data?.request}
response={data?.response}
item={item}
collection={collection}
isOauth2={true}
/>
</div>
))
) : (
<div>No debug information available.</div>
)}
</div>
</div>
);
})}
{entries.map((entry, idx) => (
<TimelineItem
key={`${entry.kind}-${idx}`}
timestamp={entry.timestamp}
request={entry.request}
response={entry.response}
item={item}
collection={collection}
isOauth2={entry.kind === 'oauth2'}
source={entry.kind === 'main' ? 'main' : (entry.kind === 'scripted' ? entry.source : undefined)}
scope={entry.kind === 'scripted' ? entry.scope : undefined}
phase={entry.kind === 'scripted' ? entry.phase : undefined}
hideTimestamp={true}
/>
))}
</StyledWrapper>
);
};

View File

@@ -40,7 +40,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
// Extract relevant data from request and response
const { method, url = '' } = effectiveRequest;
const { statusCode, statusText, duration } = response || {};
const { statusCode, duration } = response || {};
// Get event-specific icon and class names
const getEventIcon = () => {
@@ -194,7 +194,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
return (
<div className="content-status">
<div className="flex items-center gap-2">
<Status statusCode={statusCode} statusText={statusText} />
<Status statusCode={statusCode} />
</div>
{response.statusDescription && (
@@ -227,7 +227,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
</div>
<div className="flex items-center gap-2">
<Status statusCode={statusCode} statusText={statusText} />
<Status statusCode={statusCode} />
</div>
{response.trailers && response.trailers.length > 0 && (
@@ -286,7 +286,7 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection,
)}
{eventType === 'status' && (
<div className="flex items-center gap-2">
<Status statusCode={statusCode} statusText={statusText} />
<Status statusCode={statusCode} />
</div>
)}
<pre className="event-timestamp">[{new Date(timestamp).toISOString()}]</pre>

View File

@@ -10,154 +10,58 @@ const StyledWrapper = styled.div`
flex: 1;
}
.timeline-item {
border-color: ${(props) => props.theme.border.border1};
.timeline-filter-bar {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 0;
flex-wrap: wrap;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
margin-bottom: 4px;
}
.timeline-chip {
padding: 4px 10px;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
font-weight: 500;
font-family: inherit;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 7px;
transition: color 0.1s ease, background-color 0.1s ease;
&:hover {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg2 || 'rgba(255, 255, 255, 0.04)'};
}
&.is-active {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg2 || 'rgba(255, 255, 255, 0.06)'};
}
}
.timeline-chip-count {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
font-size: 11px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.timeline-chip.is-active .timeline-chip-count {
color: ${(props) => props.theme.tabs.active.border};
opacity: 1;
}
.timeline-event {
cursor: pointer;
}
.timeline-event-content {
border-radius: 4px;
padding: 12px;
margin-top: 0.5rem;
}
.timeline-event-header {
color: ${(props) => props.theme.text};
}
.method-label {
font-weight: 500;
}
.status-code {
font-weight: 500;
}
.url-text {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.base};
margin-top: 0.25rem;
}
.timestamp {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.base};
}
.meta-info {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.base};
}
.oauth-section {
.oauth-header {
display: flex;
align-items: center;
color: ${(props) => props.theme.text};
font-weight: 500;
span {
margin-left: 0.5rem;
}
}
}
.tabs-switcher {
border-bottom: 1px solid ${(props) => props.theme.border.border1};
margin-bottom: 16px;
button {
position: relative;
padding: 8px 16px;
color: ${(props) => props.theme.colors.text.muted};
&.active {
color: ${(props) => props.theme.tabs.active.color};
&:after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: ${(props) => props.theme.tabs.active.border};
}
}
}
}
.network-logs {
background: ${(props) => props.theme.codemirror.bg};
color: ${(props) => props.theme.text};
border-radius: 4px;
}
.oauth-request-item-content {
border-radius: 4px;
margin-top: 0.5rem;
}
.collapsible-section {
margin-bottom: 12px;
.section-header {
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
.line {
white-space: pre-line;
word-wrap: break-word;
word-break: break-all;
font-family: ${(props) => props.theme.font || 'Inter, sans-serif'} !important;
.arrow {
opacity: 0.5;
}
&.request {
color: ${(props) => props.theme.colors.text.green};
}
&.response {
color: ${(props) => props.theme.colors.text.purple};
}
}
.request-label {
font-size: ${(props) => props.theme.font.size.base};
padding: 2px 6px;
border-radius: 3px;
margin-left: 8px;
background: ${(props) => props.theme.requestTabs.bg};
}
table {
width: 100%;
border-collapse: collapse;
font-weight: 500;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
td {
padding: 6px 10px;
}
}
`;
export default StyledWrapper;

View File

@@ -1,35 +1,43 @@
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
import { useState } from 'react';
import { IconChevronDown, IconChevronRight } from '@tabler/icons';
import QueryResponse from 'components/ResponsePane/QueryResponse/index';
const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type }) => {
const [isBodyCollapsed, toggleBody] = useState(true);
const [isOpen, setIsOpen] = useState(true);
const hasBody = !!(data || dataBuffer);
return (
<div className="collapsible-section">
<div className="section-header" onClick={() => toggleBody(!isBodyCollapsed)}>
<pre className="flex flex-row items-center">
<div className="opacity-70">{isBodyCollapsed ? '▼' : '▶'}</div> Body
</pre>
</div>
{isBodyCollapsed && (
<div className="mt-2">
{data || dataBuffer ? (
<div className="h-96 overflow-auto">
<QueryResponse
item={item}
collection={collection}
data={data}
dataBuffer={dataBuffer}
headers={headers}
error={error}
key={item?.uid}
hideResultTypeSelector={type === 'request'}
docKey={`timeline-body:${type}:${item?.uid}`}
/>
</div>
) : (
<div className="timeline-item-timestamp">No Body found</div>
)}
</div>
<div className="tl-block">
<button
type="button"
className="tl-block-h"
aria-expanded={isOpen}
data-testid="response-body-toggle"
onClick={() => setIsOpen(!isOpen)}
>
<span className="tl-block-chev">
{isOpen ? <IconChevronDown size={12} strokeWidth={2} /> : <IconChevronRight size={12} strokeWidth={2} />}
</span>
Body
</button>
{isOpen && (
hasBody ? (
<div className="h-96 overflow-auto">
<QueryResponse
item={item}
collection={collection}
data={data}
dataBuffer={dataBuffer}
headers={headers}
error={error}
key={item?.uid}
hideResultTypeSelector={type === 'request'}
docKey={`timeline-body:${type}:${item?.uid}`}
/>
</div>
) : (
<div className="tl-empty">No Body</div>
)
)}
</div>
);

View File

@@ -1,52 +1,52 @@
import { useState } from 'react';
import { IconChevronDown, IconChevronRight } from '@tabler/icons';
const HeadersBlock = ({ headers, type }) => {
const [areHeadersCollapsed, toggleHeaders] = useState(true);
const toEntries = (headers) => {
if (!headers) return [];
if (Array.isArray(headers)) {
return headers.map((h) => ({ name: h?.name, value: h?.value }));
}
return Object.entries(headers).map(([name, value]) => ({ name, value }));
};
const Headers = ({ headers }) => {
const [isOpen, setIsOpen] = useState(true);
const entries = toEntries(headers);
const count = entries.length;
return (
<div className="collapsible-section mt-2">
<div className="section-header" onClick={() => toggleHeaders(!areHeadersCollapsed)}>
<pre className="flex flex-row items-center">
<div className="opacity-70">{areHeadersCollapsed ? '▼' : '▶'}</div> Headers
{headers && Object.keys(headers).length > 0
&& <div className="ml-1">({Object.keys(headers).length})</div>}
</pre>
</div>
{areHeadersCollapsed && (
<div className="mt-1">
{headers && Object.keys(headers).length > 0
? <Headers headers={headers} type={type} />
: <div className="timeline-item-timestamp">No Headers found</div>}
</div>
<div className="tl-block">
<button
type="button"
className="tl-block-h"
aria-expanded={isOpen}
data-testid="headers-toggle"
onClick={() => setIsOpen(!isOpen)}
>
<span className="tl-block-chev">
{isOpen ? <IconChevronDown size={12} strokeWidth={2} /> : <IconChevronRight size={12} strokeWidth={2} />}
</span>
Headers
<span className="tl-block-count">({count})</span>
</button>
{isOpen && (
count === 0
? <div className="tl-empty">No Headers</div>
: (
<table className="tl-headers-table">
<tbody>
{entries.map((h, i) => (
<tr key={i}>
<td className="tl-headers-key">{h.name}</td>
<td className="tl-headers-val">{String(h.value)}</td>
</tr>
))}
</tbody>
</table>
)
)}
</div>
);
};
const Headers = ({ headers, type }) => {
if (Array.isArray(headers)) {
return (
<div className="mt-1">
{headers.map((header, index) => (
<pre key={index} className="mb-1 whitespace-pre-wrap">
{type === 'request' ? '>' : '<'}&nbsp;<span className="opacity-60">{header?.name}:</span>
<span className="whitespace-pre-wrap">{String(header?.value)}</span>
</pre>
))}
</div>
);
} else {
return (
<div className="mt-1">
{Object.entries(headers).map(([key, value], index) => (
<pre key={index} className="mb-1 whitespace-pre-wrap">
{type === 'request' ? '>' : '<'}&nbsp;<span className="opacity-60">{key}:</span>
<span>{String(value)}</span>
</pre>
))}
</div>
);
}
};
export default HeadersBlock;
export default Headers;

View File

@@ -1,21 +1,39 @@
import React from 'react';
import { useTheme } from 'providers/Theme';
import { rgba } from 'polished';
const Status = ({ statusCode, statusText }) => {
const Status = ({ statusCode }) => {
const { theme } = useTheme();
const isStringCode = typeof statusCode === 'string' && statusCode.length > 0;
let statusColor = theme.colors.text.muted;
let color = theme.colors.text.muted;
if (statusCode >= 200 && statusCode < 300) {
statusColor = theme.requestTabPanel.responseOk;
color = theme.requestTabPanel.responseOk;
} else if (statusCode >= 300 && statusCode < 400) {
statusColor = theme.colors.text.warning;
color = theme.colors.text.warning;
} else if (statusCode >= 400 && statusCode < 600) {
statusColor = theme.requestTabPanel.responseError;
color = theme.requestTabPanel.responseError;
}
const isStatusKnown = (typeof statusCode === 'number' && statusCode > 0) || isStringCode;
const background = isStatusKnown ? rgba(color, 0.12) : 'transparent';
return (
<span className="timeline-status" style={{ color: statusColor, fontWeight: 'bold' }}>
{statusCode}{' '}
{statusText || ''}
<span
className="timeline-status"
data-testid="timeline-status"
style={{
color,
background,
fontWeight: 600,
fontSize: 11,
padding: '2px 8px',
borderRadius: 3,
letterSpacing: '0.02em',
whiteSpace: 'nowrap'
}}
>
{statusCode}
</span>
);
};

View File

@@ -0,0 +1,100 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider as SCThemeProvider } from 'styled-components';
import { ThemeContext } from 'providers/Theme';
import Status from './index';
const theme = {
colors: {
text: { muted: '#888888', warning: '#f59e0b' }
},
requestTabPanel: {
responseOk: '#22c55e',
responseError: '#ef4444'
}
};
const renderStatus = (props) =>
render(
<ThemeContext.Provider value={{ theme, displayedTheme: 'dark', storedTheme: 'system', setStoredTheme: () => {} }}>
<SCThemeProvider theme={theme}>
<Status {...props} />
</SCThemeProvider>
</ThemeContext.Provider>
);
const getPill = () => document.querySelector('.timeline-status');
describe('Timeline Status', () => {
describe('numeric HTTP codes', () => {
it('colors 2xx as success and shows a tinted background', () => {
renderStatus({ statusCode: 200 });
const pill = getPill();
expect(pill).toHaveTextContent('200');
expect(pill).toHaveStyle({ color: theme.requestTabPanel.responseOk });
expect(pill.style.background).not.toBe('transparent');
});
it('colors 3xx as warning', () => {
renderStatus({ statusCode: 301 });
expect(getPill()).toHaveStyle({ color: theme.colors.text.warning });
});
it('colors 4xx as error', () => {
renderStatus({ statusCode: 404 });
expect(getPill()).toHaveStyle({ color: theme.requestTabPanel.responseError });
});
it('colors 5xx as error', () => {
renderStatus({ statusCode: 503 });
expect(getPill()).toHaveStyle({ color: theme.requestTabPanel.responseError });
});
});
describe('string codes (pre-send network failures)', () => {
it('renders ECONNREFUSED in muted/gray (not red)', () => {
renderStatus({ statusCode: 'ECONNREFUSED' });
const pill = getPill();
expect(pill).toHaveTextContent('ECONNREFUSED');
expect(pill).toHaveStyle({ color: theme.colors.text.muted });
// String codes still get a tinted pill background so they're visible
expect(pill.style.background).not.toBe('transparent');
});
it('renders "Error" in muted/gray', () => {
renderStatus({ statusCode: 'Error' });
const pill = getPill();
expect(pill).toHaveTextContent('Error');
expect(pill).toHaveStyle({ color: theme.colors.text.muted });
});
it('renders ETIMEDOUT in muted/gray', () => {
renderStatus({ statusCode: 'ETIMEDOUT' });
expect(getPill()).toHaveStyle({ color: theme.colors.text.muted });
});
});
describe('unknown / absent codes', () => {
it('renders nothing visible when statusCode is undefined', () => {
renderStatus({ statusCode: undefined });
const pill = getPill();
// Pill still mounts but has transparent background and no text
expect(pill).toBeInTheDocument();
expect(pill.textContent).toBe('');
expect(pill.style.background).toBe('transparent');
});
it('keeps background transparent when statusCode is 0 (no real status)', () => {
renderStatus({ statusCode: 0 });
const pill = getPill();
expect(pill.style.background).toBe('transparent');
});
it('keeps background transparent for empty string', () => {
renderStatus({ statusCode: '' });
const pill = getPill();
expect(pill.style.background).toBe('transparent');
});
});
});

View File

@@ -2,16 +2,18 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
.network-logs-container {
background: ${(props) => props.theme.codemirror.bg};
color: ${(props) => props.theme.text};
border-radius: 4px;
overflow: auto;
height: 24rem;
}
.network-logs-pre {
margin: 0;
padding: 0;
background: none;
border: none;
white-space: pre-wrap;
font-size: ${(props) => props.theme.font.size.base};
word-break: break-word;
font-size: 12px;
line-height: 1.6;
font-family: var(--font-family-mono);
}
@@ -25,7 +27,7 @@ const StyledWrapper = styled.div`
&--response {
color: ${(props) => props.theme.colors.text.green};
}
&--error {
color: ${(props) => props.theme.colors.text.danger};
}
@@ -33,20 +35,20 @@ const StyledWrapper = styled.div`
&--tls {
color: ${(props) => props.theme.colors.text.purple};
}
&--info {
color: ${(props) => props.theme.colors.text.yellow};
}
}
}
.network-logs-separator {
border-top: 2px solid ${(props) => props.theme.border.border1};
border-top: 1px solid ${(props) => props.theme.border.border1};
width: 100%;
margin: 0.5rem 0;
}
.network-logs-spacing {
margin-top: 1rem;
margin-top: 0.5rem;
}
`;

View File

@@ -3,11 +3,7 @@ import BodyBlock from '../Common/Body/index';
const safeStringifyJSONIfNotString = (obj) => {
if (obj === null || obj === undefined) return '';
if (typeof obj === 'string') {
return obj;
}
if (typeof obj === 'string') return obj;
try {
return JSON.stringify(obj);
} catch (e) {
@@ -16,24 +12,24 @@ const safeStringifyJSONIfNotString = (obj) => {
};
const Request = ({ collection, request, item }) => {
let { url, headers, data, dataBuffer, error } = request || {};
let { headers, data, dataBuffer, error } = request || {};
if (!dataBuffer) {
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
}
return (
<div>
{/* Method and URL */}
<div className="mb-1 flex gap-2">
<pre className="whitespace-pre-wrap" title={url}>{url}</pre>
</div>
{/* Headers */}
<Headers headers={headers} type="request" />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} type="request" />
</div>
<>
<Headers headers={headers} />
<BodyBlock
collection={collection}
data={data}
dataBuffer={dataBuffer}
error={error}
headers={headers}
item={item}
type="request"
/>
</>
);
};

View File

@@ -1,14 +1,11 @@
import { useTheme } from 'providers/Theme';
import { formatSize } from 'utils/common';
import BodyBlock from '../Common/Body/index';
import Headers from '../Common/Headers/index';
import Status from '../Common/Status/index';
const safeStringifyJSONIfNotString = (obj) => {
if (obj === null || obj === undefined) return '';
if (typeof obj === 'string') {
return obj;
}
if (typeof obj === 'string') return obj;
try {
return JSON.stringify(obj);
} catch (e) {
@@ -16,27 +13,59 @@ const safeStringifyJSONIfNotString = (obj) => {
}
};
const statusColor = (theme, statusCode) => {
if (statusCode >= 200 && statusCode < 300) return theme.requestTabPanel.responseOk;
if (statusCode >= 300 && statusCode < 400) return theme.colors.text.warning;
if (statusCode >= 400 && statusCode < 600) return theme.requestTabPanel.responseError;
return theme.colors.text.muted;
};
const ResponseMeta = ({ code, statusText, duration, size }) => {
const { theme } = useTheme();
const sizeLabel = typeof size === 'number' ? formatSize(size) : null;
const hasCode = code != null;
const hasAny = hasCode || statusText || (typeof duration === 'number') || sizeLabel;
if (!hasAny) return null;
return (
<div className="tl-response-meta">
{(hasCode || statusText) && (
<span className="tl-response-meta-status" style={{ color: statusColor(theme, code) }}>
{code} {statusText || ''}
</span>
)}
{typeof duration === 'number' && (
<span className="tl-response-meta-item">{Math.round(duration)}ms</span>
)}
{sizeLabel && <span className="tl-response-meta-item">{sizeLabel}</span>}
</div>
);
};
const Response = ({ collection, response, item }) => {
let { status, statusCode, statusText, dataBuffer, headers, data, error } = response || {};
let { status, statusCode, statusText, dataBuffer, headers, data, error, duration, size } = response || {};
if (!dataBuffer) {
dataBuffer = Buffer.from(safeStringifyJSONIfNotString(data))?.toString('base64');
}
return (
<div>
{/* Status */}
<div className="mb-1">
<Status statusCode={status || statusCode} statusText={statusText} />
{response.duration && <span className="timeline-item-metadata">{response.duration}ms</span>}
{response.size && <span className="timeline-item-metadata">{response.size}B</span>}
</div>
{/* Headers */}
<Headers headers={headers} type="response" />
{/* Body */}
<BodyBlock collection={collection} data={data} dataBuffer={dataBuffer} error={error} headers={headers} item={item} type="response" />
</div>
<>
<ResponseMeta
code={statusCode ?? status}
statusText={statusText}
duration={duration}
size={size}
/>
<Headers headers={headers} />
<BodyBlock
collection={collection}
data={data}
dataBuffer={dataBuffer}
error={error}
headers={headers}
item={item}
type="response"
/>
</>
);
};

View File

@@ -2,111 +2,288 @@ import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.timeline-item {
border-bottom: 1px solid ${(props) => props.theme.border.border1};
padding: 0.5rem 0;
&--oauth2 {
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}
.tl-row-wrap {
min-width: 0;
}
.timeline-item-header {
.tl-row {
display: grid;
/* Badge and time use fixed widths so they line up across rows. */
grid-template-columns: 14px auto 50px minmax(0, 1fr) 96px 100px;
column-gap: 10px;
align-items: center;
cursor: pointer;
user-select: none;
transition: background-color 0.08s ease;
min-width: 0;
padding: 7px 4px;
border-top: 1px solid ${(props) => props.theme.border.border1};
}
.tl-row:hover {
background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.04)};
}
.tl-row.is-expanded {
background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.06)};
}
.tl-row:focus-visible {
outline: 2px solid ${(props) => props.theme.textLink};
outline-offset: -2px;
}
.tl-row-wrap:first-child .tl-row {
border-top: none;
}
.tl-col-chev {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.7;
line-height: 0;
display: flex;
align-items: center;
justify-content: center;
}
.tl-col-status,
.tl-col-method,
.tl-col-url,
.tl-col-badge,
.tl-col-time {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
min-width: 0;
}
.tl-col-status .timeline-status {
font-size: 11px;
}
.tl-col-method {
padding-right: 14px;
}
.tl-col-url {
color: ${(props) => props.theme.text};
font-size: 13px;
}
.tl-col-time {
color: ${(props) => props.theme.colors.text.muted};
font-size: 11px;
text-align: right;
}
.tl-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
letter-spacing: 0.02em;
background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.06)};
color: ${(props) => props.theme.colors.text.muted};
white-space: nowrap;
}
.tl-badge--main {
background: ${(props) => rgba(props.theme.colors.text.green, 0.14)};
color: ${(props) => props.theme.colors.text.green};
}
.tl-badge--oauth2 {
background: ${(props) => rgba(props.theme.textLink, 0.12)};
color: ${(props) => props.theme.textLink};
}
.tl-badge--scripted {
background: ${(props) => rgba(props.theme.colors.text.yellow, 0.12)};
color: ${(props) => props.theme.colors.text.yellow};
}
.tl-badge--run-request {
background: ${(props) => rgba(props.theme.colors.text.purple, 0.14)};
color: ${(props) => props.theme.colors.text.purple};
}
.tl-detail {
border-top: 1px dashed ${(props) => props.theme.border.border1};
margin-top: 4px;
}
.tl-header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px 10px 28px;
}
.tl-header-url {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ${(props) => props.theme.font?.mono || 'var(--font-family-mono)'};
font-size: 13px;
color: ${(props) => props.theme.text};
}
.tl-header-url-method {
font-weight: 600;
margin-right: 6px;
text-transform: uppercase;
}
.tl-header-src {
display: inline-flex;
align-items: center;
gap: 6px;
color: ${(props) => props.theme.colors.text.muted};
text-decoration: none;
cursor: pointer;
font-family: ${(props) => props.theme.font?.mono || 'var(--font-family-mono)'};
font-size: 11px;
max-width: 260px;
overflow: hidden;
}
.tl-header-src:hover {
color: ${(props) => props.theme.text};
}
.tl-header-src-file {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl-header-src-icon {
color: ${(props) => props.theme.textLink};
flex-shrink: 0;
}
/* Outer padding compensates for the first tab's 14px left padding so the
tab text lines up with the URL above. */
.tl-tabs {
display: flex;
align-items: center;
padding: 0 12px 0 14px;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}
.tl-tab {
position: relative;
padding: 9px 14px;
margin-bottom: -1px;
background: none;
border: none;
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
.timeline-item-header-content {
display: flex;
justify-content: space-between;
align-items: center;
min-width: 0;
.tl-tab:hover {
color: ${(props) => props.theme.text};
}
.tl-tab.is-active {
color: ${(props) => props.theme.tabs.active.color};
}
.tl-tab.is-active::after {
content: '';
position: absolute;
left: 14px;
right: 14px;
bottom: 0;
height: 2px;
background: ${(props) => props.theme.tabs.active.border};
}
.timeline-item-header-items {
.tl-panel {
padding: 12px 12px 14px 28px;
}
.tl-response-meta {
display: flex;
align-items: baseline;
gap: 12px;
padding: 6px 0 4px 0;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
}
.tl-response-meta-status {
font-weight: 700;
font-size: 13px;
}
.tl-response-meta-item {
color: ${(props) => props.theme.colors.text.muted};
}
.tl-block {
margin-top: 14px;
}
.tl-block:first-child {
margin-top: 0;
}
.tl-block-h {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.timeline-item-url {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 0.25rem;
gap: 8px;
padding: 6px 0;
margin-bottom: 8px;
width: 100%;
background: none;
border: none;
text-align: left;
font-family: inherit;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
user-select: none;
}
.timeline-item-timestamp {
.tl-block-h:hover {
color: ${(props) => props.theme.text};
}
.tl-block-chev {
color: ${(props) => props.theme.colors.text.muted};
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 0;
display: inline-flex;
align-items: center;
}
.timeline-item-timestamp-iso {
opacity: 0.7;
.tl-block-count {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.65;
font-weight: 500;
font-size: 11px;
text-transform: none;
letter-spacing: 0;
}
.timeline-item-oauth-label {
opacity: 0.5;
.tl-headers-table {
width: 100%;
border-collapse: collapse;
font-family: ${(props) => props.theme.font?.mono || 'var(--font-family-mono)'};
font-size: 12px;
table-layout: auto;
}
.tl-headers-table tr {
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}
.tl-headers-table tr:last-child {
border-bottom: none;
}
.tl-headers-table tr:hover {
background: ${(props) => props.theme.bg2 || rgba(props.theme.text, 0.03)};
}
.tl-headers-table td {
padding: 5px 10px 5px 0;
vertical-align: top;
word-break: break-all;
border: none;
}
.tl-headers-table td.tl-headers-key {
color: ${(props) => props.theme.colors.text.muted};
width: 220px;
min-width: 120px;
max-width: 280px;
}
.tl-headers-table td.tl-headers-val {
color: ${(props) => props.theme.text};
}
.timeline-item-content {
overflow: hidden;
}
.timeline-item-tabs {
display: flex;
margin-bottom: 1rem;
}
.timeline-item-tab {
margin-right: 1rem;
position: relative;
padding: 0.5rem 1rem;
.tl-empty {
color: ${(props) => props.theme.colors.text.muted};
background: none;
border: none;
cursor: pointer;
font-size: ${(props) => props.theme.font.size.base};
&--active {
color: ${(props) => props.theme.tabs.active.color};
&:after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: ${(props) => props.theme.tabs.active.border};
}
}
}
.timeline-item-tab-content {
word-break: break-all;
}
.timeline-item-metadata {
color: ${(props) => props.theme.colors.text.muted};
margin-left: 0.5rem;
font-size: ${(props) => props.theme.font.size.base};
}
.collapsible-section {
.section-header {
cursor: pointer;
pre {
color: ${(props) => rgba(props.theme.primary.text, 0.8)};
}
}
font-size: 12px;
padding: 6px 0;
}
`;

View File

@@ -1,83 +1,221 @@
import { useState } from 'react';
import { useTheme } from 'providers/Theme';
import Network from './Network/index';
import Request from './Request/index';
import Response from './Response/index';
import { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { IconChevronDown, IconChevronRight } from '@tabler/icons';
import Method from './Common/Method/index';
import Status from './Common/Status/index';
import { RelativeTime } from './Common/Time/index';
import Network from './Network/index';
import Request from './Request/index';
import Response from './Response/index';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState/index';
import { flattenItems } from 'utils/collections/index';
import { getRelativePath } from 'utils/common/path';
import { addTab, updateRequestPaneTab, updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { updateSettingsSelectedTab, updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
import { getBadge } from '../entryMeta';
const TimelineItem = ({ timestamp, request, response, item, collection, isOauth2, hideTimestamp = false }) => {
const { theme } = useTheme();
const [isCollapsed, _toggleCollapse] = usePersistedState({
const findFolderByScopeFile = (collection, sourceFile) => {
if (!collection?.pathname || !sourceFile) return null;
const dir = sourceFile.replace(/\/folder\.(?:bru|yml)$/, '');
if (!dir || dir === sourceFile) return null;
return flattenItems(collection.items || []).find(
(i) => i.type === 'folder' && getRelativePath(collection.pathname, i.pathname) === dir
) || null;
};
const TimelineItem = ({
timestamp,
request,
response,
error,
item,
collection,
isOauth2,
hideTimestamp = false,
source,
scope,
phase
}) => {
const dispatch = useDispatch();
const [isExpanded, _toggleExpand] = usePersistedState({
key: `timeline-${timestamp}`,
default: false
});
const [activeTab, setActiveTab] = useState('request');
const toggleCollapse = () => _toggleCollapse((prev) => !prev);
const { method, status, statusCode, statusText, url = '' } = request || {};
const { status: responseStatus, statusCode: responseStatusCode, statusText: responseStatusText } = response || {};
const showNetworkLogs = response.timeline && response.timeline.length > 0;
// CodeMirror reads its size on mount and stays blank if hidden. Lazy-mount
// each tab on first visit and keep it mounted, toggling display only.
const [visitedTabs, setVisitedTabs] = useState({ request: true });
const toggleExpand = () => _toggleExpand((prev) => !prev);
const handleRowKeyDown = (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
toggleExpand();
}
};
useEffect(() => {
if (isExpanded) setVisitedTabs({ [activeTab]: true });
}, [isExpanded]);
const handleTabClick = (id) => {
setActiveTab(id);
setVisitedTabs((v) => (v[id] ? v : { ...v, [id]: true }));
};
const { method, url = '' } = request || {};
// Main-request entries use `status`; scripted entries use `statusCode`.
const { status, statusCode, statusText } = response || {};
const numericCode = typeof statusCode === 'number'
? statusCode
: typeof status === 'number'
? status
: null;
const code = numericCode != null
? numericCode
: (statusText || (error ? 'Error' : undefined));
const showNetworkLogs = response?.timeline && response.timeline.length > 0;
const badge = getBadge({ source, isOauth2 });
const isMainOrOauth = !source || source === 'main' || isOauth2;
const scopeType = scope?.type || (isMainOrOauth ? null : 'request');
const requestExt = collection?.format === 'yml' ? '.yml' : '.bru';
const scopeFile = scope?.sourceFile
|| (scopeType === 'request' ? (item?.filename || (item?.name ? `${item.name}${requestExt}` : null)) : null);
const sourceFile = isMainOrOauth ? null : scopeFile;
const folderForScope = scopeType === 'folder'
? findFolderByScopeFile(collection, scope?.sourceFile)
: null;
const navTarget = (() => {
if (!collection?.uid) return null;
if (scopeType === 'collection') return { kind: 'collection' };
if (scopeType === 'folder' && folderForScope?.uid) return { kind: 'folder', uid: folderForScope.uid };
if (scopeType === 'request' && item?.uid) return { kind: 'request', uid: item.uid };
return null;
})();
const canNavigate = !!navTarget;
const handleNavigate = (ev) => {
ev?.preventDefault?.();
ev?.stopPropagation?.();
if (!navTarget) return;
// Collection settings expect tab 'tests' (plural); folder settings expect 'test' (singular).
const isTestsPhase = phase === 'tests';
const scriptPaneTab = phase || 'pre-request';
if (navTarget.kind === 'collection') {
dispatch(addTab({ uid: collection.uid, collectionUid: collection.uid, type: 'collection-settings' }));
if (isTestsPhase) {
dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'tests' }));
} else {
dispatch(updateSettingsSelectedTab({ collectionUid: collection.uid, tab: 'script' }));
dispatch(updateScriptPaneTab({ uid: collection.uid, scriptPaneTab }));
}
} else if (navTarget.kind === 'folder') {
dispatch(addTab({ uid: navTarget.uid, collectionUid: collection.uid, type: 'folder-settings' }));
if (isTestsPhase) {
dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: navTarget.uid, tab: 'test' }));
} else {
dispatch(updatedFolderSettingsSelectedTab({ collectionUid: collection.uid, folderUid: navTarget.uid, tab: 'script' }));
dispatch(updateScriptPaneTab({ uid: navTarget.uid, scriptPaneTab }));
}
} else if (navTarget.kind === 'request') {
dispatch(addTab({ uid: navTarget.uid, collectionUid: collection.uid, type: 'request' }));
if (isTestsPhase) {
dispatch(updateRequestPaneTab({ uid: navTarget.uid, requestPaneTab: 'tests' }));
} else {
dispatch(updateRequestPaneTab({ uid: navTarget.uid, requestPaneTab: 'script' }));
dispatch(updateScriptPaneTab({ uid: navTarget.uid, scriptPaneTab }));
}
}
};
const tabs = [
{ id: 'request', label: 'Request' },
{ id: 'response', label: 'Response' },
...(showNetworkLogs ? [{ id: 'network', label: 'Network' }] : [])
];
return (
<StyledWrapper>
<div className={`timeline-item ${isOauth2 ? 'timeline-item--oauth2' : ''}`}>
<div className="oauth-request-item-header relative cursor-pointer flex items-center justify-between gap-3 min-w-0" onClick={toggleCollapse}>
<Status statusCode={responseStatus || responseStatusCode} statusText={responseStatusText} />
<div className="flex items-center gap-1">
<div className={`tl-row-wrap ${isOauth2 ? 'tl-row-wrap--oauth2' : ''}`} data-testid="timeline-entry">
<div
className={`tl-row ${isExpanded ? 'is-expanded' : ''}`}
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={toggleExpand}
onKeyDown={handleRowKeyDown}
data-testid="timeline-item-header"
>
<div className="tl-col-chev">
{isExpanded ? <IconChevronDown size={14} strokeWidth={2} /> : <IconChevronRight size={14} strokeWidth={2} />}
</div>
<div className="tl-col-status">
<Status statusCode={code} />
</div>
<div className="tl-col-method">
<Method method={method} />
<div className="truncate flex-1 min-w-0">{url}</div>
{isOauth2 && <span className="text-xs flex-shrink-0" style={{ color: theme.colors.text.muted }}>[oauth2.0]</span>}
</div>
<div className="tl-col-url" title={url} data-testid="timeline-url">{url}</div>
<div className="tl-col-badge">
<span className={badge.badgeClass} data-testid={`timeline-badge-${badge.kind}`}>{badge.badgeLabel}</span>
</div>
{!hideTimestamp && (
<span className="flex-shrink-0 ml-auto">
<div className="tl-col-time">
<RelativeTime timestamp={timestamp} />
</span>
</div>
)}
</div>
{isCollapsed && (
<div className="timeline-item-content">
{/* Tabs */}
<div className="timeline-item-tabs">
<button
className={`timeline-item-tab ${activeTab === 'request' ? 'timeline-item-tab--active' : ''}`}
onClick={() => setActiveTab('request')}
>
Request
</button>
<button
className={`timeline-item-tab ${activeTab === 'response' ? 'timeline-item-tab--active' : ''}`}
onClick={() => setActiveTab('response')}
>
Response
</button>
{showNetworkLogs && (
<button
className={`timeline-item-tab ${activeTab === 'networkLogs' ? 'timeline-item-tab--active' : ''}`}
onClick={() => setActiveTab('networkLogs')}
{isExpanded && (
<div className="tl-detail" data-testid="timeline-detail">
<div className="tl-header">
<div className="tl-header-url" title={`${method || ''} ${url}`}>
<span className="tl-header-url-method">{method}</span>
<span className="tl-header-url-text">{url}</span>
</div>
{sourceFile && (
<a
className={`tl-header-src${canNavigate ? '' : ' is-disabled'}`}
href="#"
title={canNavigate ? `Open ${sourceFile}` : sourceFile}
onClick={canNavigate ? handleNavigate : (ev) => ev.preventDefault()}
data-testid="timeline-source-link"
>
Network Logs
</button>
<span className="tl-header-src-file" data-testid="timeline-source-file">{sourceFile}</span>
<span className="tl-header-src-icon"></span>
</a>
)}
</div>
{/* Tab Content */}
<div className="timeline-item-tab-content">
{/* Request Tab */}
{activeTab === 'request' && (
<Request request={request} item={item} collection={collection} />
)}
<div className="tl-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
className={`tl-tab ${activeTab === tab.id ? 'is-active' : ''}`}
onClick={() => handleTabClick(tab.id)}
>
{tab.label}
</button>
))}
</div>
{/* Response Tab */}
{activeTab === 'response' && (
<Response response={response} item={item} collection={collection} />
<div className="tl-panel">
{visitedTabs.request && (
<div style={{ display: activeTab === 'request' ? 'block' : 'none' }}>
<Request request={request} item={item} collection={collection} />
</div>
)}
{/* Network Logs Tab */}
{activeTab === 'networkLogs' && showNetworkLogs && (
<Network logs={response?.timeline} />
{visitedTabs.response && (
<div style={{ display: activeTab === 'response' ? 'block' : 'none' }}>
<Response response={response} item={item} collection={collection} />
</div>
)}
{showNetworkLogs && visitedTabs.network && (
<div style={{ display: activeTab === 'network' ? 'block' : 'none' }}>
<Network logs={response?.timeline} />
</div>
)}
</div>
</div>

View File

@@ -0,0 +1,78 @@
export const getEntryKind = (entry) => {
if (entry.type === 'request') return 'main';
if (entry.type === 'oauth2') return 'oauth';
if (entry.type === 'scripted-request') {
// 'post-response' and 'tests' both run after the main response bucket together.
if (entry.phase === 'post-response' || entry.phase === 'tests') return 'post';
return 'pre';
}
return 'main';
};
const findPairedMainTimestamps = (fullTimeline) => {
const map = new Map();
fullTimeline.forEach((entry, idx) => {
if (entry.type !== 'oauth2') return;
for (let j = idx + 1; j < fullTimeline.length; j++) {
const candidate = fullTimeline[j];
if (
candidate.type === 'request'
&& candidate.itemUid === entry.itemUid
&& typeof candidate.timestamp === 'number'
) {
map.set(idx, candidate.timestamp);
break;
}
}
});
return map;
};
const isVisibleEntry = (entry, itemUid, authSource) => {
if (entry.itemUid === itemUid) return true;
if (entry.type === 'oauth2' && authSource) {
if (authSource.type === 'folder' && entry.folderUid === authSource.uid) return true;
if (authSource.type === 'collection' && !entry.folderUid) return true;
}
return false;
};
const expandOauthEntry = (entry, paired) => {
const debugInfo = entry.data?.debugInfo || [];
// No sub-calls to render drop the parent so the OAuth chip count
if (debugInfo.length === 0) return [];
const n = debugInfo.length;
const mainAnchor = paired != null ? paired : entry.timestamp + n;
return debugInfo.map((sub, i) => ({
...entry,
timestamp: mainAnchor - (n - i),
_oauth2Child: sub
}));
};
export const buildTimelineEntries = (timeline, itemUid, authSource) => {
const fullTimeline = timeline || [];
const visible = fullTimeline.filter((entry) => isVisibleEntry(entry, itemUid, authSource));
const pairedMainByOauthIdx = findPairedMainTimestamps(fullTimeline);
const flat = [];
visible.forEach((entry) => {
if (entry.type === 'oauth2') {
const paired = pairedMainByOauthIdx.get(fullTimeline.indexOf(entry));
flat.push(...expandOauthEntry(entry, paired));
} else {
flat.push(entry);
}
});
return flat.sort((a, b) => b.timestamp - a.timestamp);
};
export const countByKind = (entries) => {
const counts = { all: entries.length, main: 0, pre: 0, post: 0, oauth: 0 };
entries.forEach((entry) => {
const kind = getEntryKind(entry);
if (counts[kind] != null) counts[kind]++;
});
return counts;
};

View File

@@ -0,0 +1,23 @@
// Keys must match getEntryKind() in buildEntries.js.
// `kind` is a stable identifier used for data-testids (e.g. timeline-badge-pre).
export const ENTRY_KINDS = {
main: { kind: 'main', chipLabel: 'Request', badgeLabel: 'request', badgeClass: 'tl-badge tl-badge--main' },
oauth: { kind: 'oauth', chipLabel: 'OAuth', badgeLabel: 'oauth2.0', badgeClass: 'tl-badge tl-badge--oauth2' },
pre: { kind: 'pre', chipLabel: 'Pre-Request', badgeLabel: 'sendRequest', badgeClass: 'tl-badge tl-badge--scripted' },
post: { kind: 'post', chipLabel: 'Post-Response', badgeLabel: 'runRequest', badgeClass: 'tl-badge tl-badge--run-request' }
};
export const FILTER_CHIPS = [
{ id: 'all', label: 'All' },
{ id: 'main', label: ENTRY_KINDS.main.chipLabel },
{ id: 'pre', label: ENTRY_KINDS.pre.chipLabel },
{ id: 'post', label: ENTRY_KINDS.post.chipLabel },
{ id: 'oauth', label: ENTRY_KINDS.oauth.chipLabel }
];
export const getBadge = ({ source, isOauth2 }) => {
if (isOauth2) return ENTRY_KINDS.oauth;
if (!source || source === 'main') return ENTRY_KINDS.main;
if (source === 'runRequest') return ENTRY_KINDS.post;
return ENTRY_KINDS.pre;
};

View File

@@ -1,4 +1,4 @@
import React, { useRef } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import StyledWrapper from './StyledWrapper';
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
import { get } from 'lodash';
@@ -6,6 +6,8 @@ import TimelineItem from './TimelineItem/index';
import GrpcTimelineItem from './GrpcTimelineItem/index';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
import { buildTimelineEntries, getEntryKind, countByKind } from './buildEntries';
import { FILTER_CHIPS } from './entryMeta';
const getEffectiveAuthSource = (collection, item) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
@@ -49,42 +51,66 @@ const Timeline = ({ collection, item }) => {
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `response-timeline-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: null, onChange: setScroll, initialValue: scroll });
const [activeFilter, setActiveFilter] = useState('all');
// Get the effective auth source if auth mode is inherit
const authSource = getEffectiveAuthSource(collection, item);
const itemAuthMode = item.draft?.request?.auth?.mode ?? item.request?.auth?.mode ?? item.root?.request?.auth?.mode;
const authSource = useMemo(
() => getEffectiveAuthSource(collection, item),
[item, itemAuthMode, collection]
);
const isGrpcRequest = item.type === 'grpc-request' || item.type === 'ws-request';
// Filter timeline entries based on new rules
const combinedTimeline = ([...(collection?.timeline || [])]).filter((obj) => {
// Always show entries for this item
if (obj.itemUid === item.uid) return true;
const entries = useMemo(
() => buildTimelineEntries(collection?.timeline, item.uid, authSource),
[collection?.timeline, item.uid, authSource]
);
const counts = useMemo(() => countByKind(entries), [entries]);
// For OAuth2 entries, also show if auth is inherited
if (obj.type === 'oauth2' && authSource) {
if (authSource.type === 'folder' && obj.folderUid === authSource.uid) return true;
if (authSource.type === 'collection' && !obj.folderUid) return true;
}
const visibleChips = FILTER_CHIPS.filter((chip) => chip.id === 'all' || counts[chip.id] > 0);
const hasOtherKinds = counts.pre > 0 || counts.post > 0 || counts.oauth > 0;
const showFilterBar = entries.length > 0 && hasOtherKinds;
return false;
}).sort((a, b) => b.timestamp - a.timestamp);
useEffect(() => {
if (activeFilter === 'all') return;
const stillVisible = visibleChips.some((chip) => chip.id === activeFilter);
if (!stillVisible) setActiveFilter('all');
}, [activeFilter, visibleChips]);
return (
<StyledWrapper
className="pb-4 w-full flex flex-grow flex-col"
ref={wrapperRef}
>
{/* Timeline container with scrollbar */}
<div
className="timeline-container"
>
{combinedTimeline.map((event, index) => {
// Handle regular requests
if (event.type === 'request') {
const { data, timestamp, eventType } = event;
{showFilterBar && (
<div className="timeline-filter-bar" data-testid="timeline-filter-bar">
{visibleChips.map((chip) => (
<button
key={chip.id}
type="button"
className={`timeline-chip ${activeFilter === chip.id ? 'is-active' : ''}`}
onClick={() => setActiveFilter(chip.id)}
data-testid={`timeline-chip-${chip.id}`}
>
{chip.label}
<span className="timeline-chip-count" data-testid="timeline-chip-count">{counts[chip.id] ?? 0}</span>
</button>
))}
</div>
)}
<div className="timeline-container" data-testid="timeline-container">
{entries.map((entry, index) => {
const kind = getEntryKind(entry);
if (activeFilter !== 'all' && activeFilter !== kind) return null;
if (entry.type === 'request') {
const { data, timestamp, eventType } = entry;
const { request, response, eventData = {}, timestamp: eventTimestamp = timestamp } = data;
if (isGrpcRequest) {
return (
<div key={index} className="timeline-event">
<div key={index} className="timeline-event" data-testid="timeline-item">
<GrpcTimelineItem
timestamp={eventTimestamp}
request={request}
@@ -98,46 +124,50 @@ const Timeline = ({ collection, item }) => {
);
}
// Regular HTTP request
return (
<div key={index} className="timeline-event">
<div key={index} className="timeline-event" data-testid="timeline-item">
<TimelineItem
timestamp={timestamp}
request={request}
response={response}
item={item}
collection={collection}
source="main"
/>
</div>
);
} else if (event.type === 'oauth2') { // Handle OAuth2 events
const { data, timestamp } = event;
const { debugInfo } = data;
}
if (entry.type === 'oauth2' && entry._oauth2Child) {
return (
<div key={index} className="timeline-event">
<div className="timeline-event-header cursor-pointer flex items-center">
<div className="flex items-center">
<span className="font-bold">OAuth2.0 Calls</span>
</div>
</div>
<div className="mt-2">
{debugInfo && debugInfo.length > 0 ? (
debugInfo.map((data, idx) => (
<div className="ml-4" key={idx}>
<TimelineItem
timestamp={timestamp}
request={data?.request}
response={data?.response}
item={item}
collection={collection}
isOauth2={true}
/>
</div>
))
) : (
<div>No debug information available.</div>
)}
</div>
<div key={index} className="timeline-event" data-testid="timeline-item">
<TimelineItem
timestamp={entry.timestamp}
request={entry._oauth2Child.request}
response={entry._oauth2Child.response}
item={item}
collection={collection}
source="oauth2.0"
isOauth2={true}
/>
</div>
);
}
if (entry.type === 'scripted-request') {
return (
<div key={index} className="timeline-event" data-testid="timeline-item">
<TimelineItem
timestamp={entry.timestamp}
request={entry.data?.request}
response={entry.data?.response}
error={entry.data?.error}
item={item}
collection={collection}
source={entry.source || 'sendRequest'}
scope={entry.scope}
phase={entry.phase}
/>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
background: ${({ theme }) => theme.background.crust};
border: 1px solid ${({ theme }) => theme.border.border0};
border-radius: ${({ theme }) => theme.border.radius.sm};
.request-name {
color: ${({ theme }) => theme.text};
}
.collection-name{
color: ${({ theme }) => theme.colors.text.subtext1};
}
`;
export default StyledWrapper;

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