Compare commits

...

81 Commits

Author SHA1 Message Date
Bijin A B
cd8bc459ce fix(playwright): fix flaky tests 2026-06-20 16:21:28 +05:30
Bijin A B
a93e1dc8bf test: fix test and update default retry in local to 0 (#8316) 2026-06-20 02:17:14 +05:30
sachin-bruno
4fffef51ba Feature(size/L): BRU-2542 Choose environments to include and show versions in the Generate Documentation modal (#8268)
* BRU-1128 bug fix OpenAPI import error message

* Feat:BRU-2542 Choose environments to include and show UI

* Feat:BRU-2542 Added virtualization for env lists

* Feat:BRU-2542 Reverted IPCError modal changes

* BRU-2542 removed wait mount from playwrite script

* BRU-2542 Added select all checkbox

* BRU-2542 Added portals for generate doc modal and fixed overlapping issue

* BRU-2542 Comments addressed

---------

Co-authored-by: bruno-sachin <bruno-sachin@brunos-MacBook-Air.local>
2026-06-20 01:30:16 +05:30
Bijin A B
6136d3ac62 tests: run playwright e2e fully parallel (#8313) 2026-06-19 19:40:24 +05:30
lohit
942f995717 feat: variable data types support (#8046) 2026-06-19 19:36:59 +05:30
naman-bruno
82ee8e1331 add: change log tab (#8289) 2026-06-19 15:10:20 +05:30
prateek-bruno
6711ccdda2 feat: redesign notification modal (#8140)
* fix: test expect

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

* fix: maintain state for read and cleared notification ids

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

* feat: revamp Notifications

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

* fix: break things into components and use events

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

* fix: icon + more padding

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

* chore: use classnames

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

* chore: remove redundancy

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

* chore: make it pixel accurate

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

* fix: remove redundant useMemo

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

* fix: colors of notification modal

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

* fix: use color paletter + fix badge color

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

* fix: ensure semantics for notification icon

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

* fix: handle keyboard navigation for drawer items

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

* fix: colors, no notification view, etc

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

* fix: don't crash on color of badge that is invalid

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

* fix: use hex color for type of notification
Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix

* Apply suggestions from code review

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

* fix: use parseToRgb instead of custom isHexColor check + add unit tests

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

* fix: pointer events getting swallowed by iframe, causing resize issue

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

---------

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>
Co-authored-by: naman-bruno <naman@usebruno.com>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-06-18 21:28:47 +05:30
Pooja
a7efed674e feat(websocket): show full message name in a tooltip on hover (#8299) 2026-06-18 19:41:58 +05:30
Chirag Chandrashekhar
5345cb7b5f fix(transient): scope bruno- lookup to transient base, not full path (#8273) 2026-06-18 17:35:27 +05:30
prateek-bruno
36e59e992c fix: correctly parse insomnia exported yaml (#7966) 2026-06-18 14:07:00 +05:30
sachin-bruno
fab18d9e3e Feature (Size/M) : BRU-3560 Remove Beta badge from openAPI sync (#8250)
* BRU-3560 Remove Beta badge from openAPI sync

* BRU-3560 Comments fixed

* BRU-3560 Comments resolved

* BRU-3560 Comments resolved

* BRU-3560 Comments resolved

---------

Co-authored-by: bruno-sachin <bruno-sachin@brunos-MacBook-Air.local>
2026-06-17 19:24:26 +05:30
ravindra-bruno
7b94e069e9 feat(import): hide File Format option and default collections to Open… (#8247) 2026-06-17 16:56:33 +05:30
naman-bruno
ba063f6d82 feat: implement file mode (#8258)
* feat: implement file mode
2026-06-17 16:34:18 +05:30
Utkarsh
c857d27415 fix(postman-migration): install packages report npm install failed (#8284) 2026-06-17 14:17:10 +05:30
Abhishek Patil
3c576487c9 fix(perf):quickjs-memory-leak (#8219) 2026-06-17 13:46:14 +05:30
rajashreehj-bruno
277845b6d8 Fix (import): Postman import: OAuth2 tokenPlacement not set correctly, Header Prefix field hidden and value lost (#8197)
* Fix (oauth2): Postman import: OAuth2 tokenPlacement not set correctly, Header Prefix field hidden and value lost

* Add assert to persistes values

* id name change

* replace hardcoded timeout

* grant type fix

* missing keys in process auth

* process auth
2026-06-17 11:00:52 +05:30
vijayh-bruno
1907b2b3f0 add vijay to CODEOWNERS (#8278)
Co-authored-by: Vijay H <vijayh@usebruno.com>
2026-06-16 22:52:23 +05:30
Sundram
05ab2661fa feat(openapi-sync): preserve user-configured request values on sync (#8204)
Reconcile request structure against the spec on sync while preserving the
user's values (JSON body, params, headers, auth) and {{var}} references for
fields that still exist. A "Preserve values" toggle (default on) on the Spec
Updates review controls it; turning it off lets spec values overwrite. The diff
preview's EXPECTED column shows the post-merge result so unchanged values do not
render as changes.

- field-level merge for JSON body (by key path), form fields and params/headers
  (by name, duplicate names paired positionally), preserving value + enabled
- {{var}} masking so interpolated bodies parse, merge and restore safely,
  using a private-use sentinel that never collides with body text
- auth merged by mode: same mode keeps the user's values and adds spec-introduced
  fields; a mode change takes the spec. compareRequestFields compares auth by
  mode only, so preserved auth values no longer mark the collection out of sync
- preserveValues threaded through apply and diff-preview IPC handlers
- reset path left unchanged; scripts/tests/assertions preserved in sync and reset
- 67 unit tests covering the merge helpers and masking edge cases
2026-06-16 20:01:52 +05:30
Utkarsh
07c7348666 BRU-3246 fix - added a param check method replacing the param null check (#8157) 2026-06-16 15:30:04 +05:30
Pooja
b73bf9d898 feat(app): scroll to and highlight error line on script error (#8183) 2026-06-15 13:01:19 +05:30
gopu-bruno
9d8c0fd2a0 fix(ui): open response pane at a minimum height on expand (#8236) 2026-06-15 12:36:54 +05:30
Sundram
2bc735ee00 fix(apispec): prevent crash on non-array specs and fix Windows spec listing (BRU-3556) (#8255) 2026-06-12 23:43:27 +05:30
Utkarsh
1472f6b158 Merge pull request #8253 from utkarsh-bruno/fix/BRU-3531 2026-06-12 23:41:30 +05:30
lohit
d8d468f1e0 feat: support annotations for secret environment variables in bru and preserve variable value type in yml (#8251) 2026-06-12 23:18:41 +05:30
Sid
0d73e38515 fix(snapshot): folder nested script tab interactivity and tests (#8225)
* fix(snapshot): folder script interactivity

* fix: add tests for collection scripts
2026-06-12 19:13:34 +05:30
Sid
cff1f25528 chore: sec updates (#8193)
* chore: reset + atomic updates

* chore: surgically update protobufjs

* chore: dedupe axios
2026-06-12 19:01:15 +05:30
sachin-thakur-bruno
db195fe302 feat(dev-tools-rquest-resize): dev tools details panel can be resized horizontally via a drag handle (#8234) 2026-06-12 18:10:02 +05:30
sachin-thakur-bruno
e7e6cdfa51 feat(dev-tools)/adds sorting on columns with verticle borders (#8238) 2026-06-12 17:58:22 +05:30
gopu-bruno
7a24b1924d fix(workspace): keep workspace nav tabs visible when editing docs (#8249) 2026-06-12 17:19:15 +05:30
Bhavik Mehta
13363d7931 fix: show unsaved changes prompt when closing tab with Cmd+W (#8245) 2026-06-12 12:41:25 +05:30
gopu-bruno
1d3a412539 feat(workspace): move external collections into the workspace (#8196) 2026-06-12 11:22:53 +05:30
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
682 changed files with 41016 additions and 4986 deletions

2
.github/CODEOWNERS vendored
View File

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

View File

@@ -18,7 +18,7 @@ runs:
- 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'

View File

@@ -49,7 +49,7 @@ jobs:
e2e-test:
name: Playwright E2E Tests (Linux)
timeout-minutes: 120
timeout-minutes: 240
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -59,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
@@ -69,7 +70,7 @@ jobs:
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run playwright Tests
- name: Run E2E Tests
uses: ./.github/actions/tests/run-e2e-tests
with:
os: ubuntu

View File

@@ -49,7 +49,7 @@ jobs:
e2e-test:
name: Playwright E2E Tests (macOS)
timeout-minutes: 150
timeout-minutes: 240
runs-on: macos-latest
steps:
- uses: actions/checkout@v6

View File

@@ -58,7 +58,7 @@ jobs:
e2e-test:
name: Playwright E2E Tests (Windows)
timeout-minutes: 120
timeout-minutes: 240
runs-on: windows-latest
steps:
- uses: actions/checkout@v6

264
package-lock.json generated
View File

@@ -30,7 +30,6 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.1",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -39,6 +38,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",
@@ -50,7 +50,7 @@
"globals": "^16.1.0",
"husky": "^9.1.7",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"lodash-es": "^4.17.23",
"nano-staged": "^0.8.0",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
@@ -67,6 +67,84 @@
"dev": true,
"license": "MIT"
},
"node_modules/@ai-sdk/anthropic": {
"version": "3.0.15",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.15.tgz",
"integrity": "sha512-FCNy6pABPe5Qb1VPbdLLIi/XkQN2g/fKUcl1GcXxIU3Ofr+vOND8cyZfH20cMODR523FSGfwswJoJic8skr8qg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.4",
"@ai-sdk/provider-utils": "4.0.8"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.16.tgz",
"integrity": "sha512-OOY5CfRJiHvh/8np2vs1RQaCZ5hWv2qOeEmmeiABXK3gLQHUVnCO+1hhoLsZdHM5iElu6M407dAOfyvTsKJqcQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.4",
"@ai-sdk/provider-utils": "4.0.8",
"@vercel/oidc": "3.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.12",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.12.tgz",
"integrity": "sha512-zqLWEKuaKnjXhu7xCw1jgz/+yTbd3F7EtgU4T2Q8BAo8OJC5wZv14l+kwM7Jai7M1/2Y2T/zBkrfiIu+7NsvfQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.4",
"@ai-sdk/provider-utils": "4.0.8"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.4.tgz",
"integrity": "sha512-5KXyBOSEX+l67elrEa+wqo/LSsSTtrPj9Uoh3zMbe/ceQX4ucHI3b9nUEfNkGF3Ry1svv90widAt+aiKdIJasQ==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.8.tgz",
"integrity": "sha512-ns9gN7MmpI8vTRandzgz+KK/zNMLzhrriiKECMt4euLtQFSBgNfydtagPOX4j4pS1/3KvHF6RivhT3gNQgBZsg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.4",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -6865,6 +6943,16 @@
"dev": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -9606,6 +9694,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz",
@@ -10048,9 +10145,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/primitive": {
@@ -11271,6 +11368,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@storybook/addon-webpack5-compiler-babel": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@storybook/addon-webpack5-compiler-babel/-/addon-webpack5-compiler-babel-4.0.0.tgz",
@@ -12290,7 +12393,7 @@
"@swagger-api/apidom-core": "^1.4.0",
"@swagger-api/apidom-error": "^1.4.0",
"@types/ramda": "~0.30.0",
"axios": "1.13.6",
"axios": "1.16.0",
"minimatch": "^7.4.3",
"process": "^0.11.10",
"ramda": "~0.30.0",
@@ -12812,6 +12915,13 @@
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsdom": {
"version": "20.0.1",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",
@@ -13401,6 +13511,15 @@
"resolved": "packages/bruno-toml",
"link": true
},
"node_modules/@vercel/oidc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -14074,6 +14193,24 @@
"node": ">= 14"
}
},
"node_modules/ai": {
"version": "6.0.39",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.39.tgz",
"integrity": "sha512-hF05gF4H+IxuilA8kNANVVHQXduTJsJaH74jmlmy8mcQt3NZgPYe2zZNyGBV4DPDYTUDt1h31hbLgQqJTn5LGA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.16",
"@ai-sdk/provider": "3.0.4",
"@ai-sdk/provider-utils": "4.0.8",
"@opentelemetry/api": "1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -14601,9 +14738,9 @@
}
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
@@ -14617,7 +14754,7 @@
"integrity": "sha512-CS6WE8chZpEDKxv4IFwr5zcG7InMC6Ek0aj2n2tHauBh+8KiYVC4qMn3N2arjR5tnyILQuTGlI0mc83hgWxS4Q==",
"license": "MIT",
"dependencies": {
"axios": "1.13.6",
"axios": "1.16.0",
"des.js": "^1.1.0",
"dev-null": "^0.1.1",
"js-md4": "^0.3.2"
@@ -16105,6 +16242,21 @@
"node": ">=0.2.5"
}
},
"node_modules/cli-table3": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz",
"integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==",
"license": "MIT",
"dependencies": {
"string-width": "^4.2.0"
},
"engines": {
"node": "10.* || >= 12.*"
},
"optionalDependencies": {
"@colors/colors": "1.5.0"
}
},
"node_modules/cli-truncate": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
@@ -16469,7 +16621,7 @@
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"shell-quote": "^1.8.4",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
@@ -19009,6 +19161,15 @@
"bare-events": "^2.7.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
"integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/evp_bytestokey": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
@@ -23397,6 +23558,12 @@
"node": "*"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -23700,15 +23867,15 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.0.tgz",
"integrity": "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.0.tgz",
"integrity": "sha512-koAgswPPA+UTaPN64Etp+PGP+WT6oqOS2NMi5yDkMaiGw9qY4VxQbQF0mtKMyr4BlTznWyzePV5UpECTJQmSUA==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
@@ -26584,7 +26751,7 @@
"integrity": "sha512-l+fsjYEkTik3m/G0pE7gMr4qBJP84LhK779oQm6MBzhBGpd4By4qieTW+4FUAlNCyzQTynn3Nhsa50c0IELSxQ==",
"license": "MIT",
"dependencies": {
"axios": "1.13.6",
"axios": "1.16.0",
"rusha": "^0.8.14"
},
"engines": {
@@ -26841,9 +27008,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
"integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
"integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -29420,10 +29587,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
"dev": true,
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -32742,6 +32908,15 @@
"node": ">= 14"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"packages/bruno-app": {
"name": "@usebruno/app",
"version": "2.0.0",
@@ -32821,7 +32996,7 @@
"react-virtuoso": "^4.18.1",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"shell-quote": "^1.8.4",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "^5.31.0",
@@ -34312,18 +34487,6 @@
"node": ">=10"
}
},
"packages/bruno-app/node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-app/node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
@@ -34374,10 +34537,11 @@
"@usebruno/lang": "0.12.0",
"@usebruno/requests": "^0.1.0",
"aws4-axios": "^3.3.15",
"axios": "1.13.6",
"axios": "1.16.0",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
"cli-table3": "^0.6.5",
"decomment": "^0.9.5",
"form-data": "4.0.4",
"fs-extra": "^10.1.0",
@@ -34918,6 +35082,7 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@usebruno/common": "^0.1.0",
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.1",
"jscodeshift": "^17.3.0",
@@ -34928,6 +35093,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.25.0",
"@opencollection/types": "0.9.1",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
@@ -35011,6 +35177,8 @@
"name": "bruno",
"version": "2.0.0",
"dependencies": {
"@ai-sdk/anthropic": "3.0.15",
"@ai-sdk/openai": "3.0.12",
"@aws-sdk/credential-providers": "3.1019.0",
"@grpc/grpc-js": "^1.13.2",
"@grpc/proto-loader": "^0.7.13",
@@ -35025,9 +35193,10 @@
"@usebruno/schema": "0.7.0",
"about-window": "^1.15.2",
"adm-zip": "^0.5.16",
"ai": "6.0.39",
"archiver": "^7.0.1",
"aws4-axios": "^3.3.15",
"axios": "1.13.6",
"axios": "1.16.0",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
@@ -35057,7 +35226,8 @@
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^10.0.0",
"yup": "^0.32.11"
"yup": "^0.32.11",
"zod": "^4.1.8"
},
"devDependencies": {
"electron": "~37.6.1",
@@ -35595,6 +35765,7 @@
"license": "MIT",
"dependencies": {
"@types/nanoid": "^2.1.0",
"@usebruno/common": "0.1.0",
"@usebruno/lang": "0.12.0",
"ajv": "^8.17.1",
"lodash": "^4.17.21",
@@ -35603,6 +35774,7 @@
"devDependencies": {
"@babel/preset-env": "^7.22.0",
"@babel/preset-typescript": "^7.22.0",
"@opencollection/types": "0.9.1",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -35802,7 +35974,7 @@
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"atob": "^2.1.2",
"axios": "1.13.6",
"axios": "1.16.0",
"btoa": "^1.2.1",
"chai": "^4.3.7",
"chai-string": "^1.5.0",
@@ -35978,6 +36150,7 @@
"version": "0.12.0",
"license": "MIT",
"dependencies": {
"@usebruno/common": "0.1.0",
"arcsecond": "^5.0.0",
"dotenv": "^16.3.1",
"lodash": "^4.17.21",
@@ -36008,7 +36181,7 @@
"@grpc/grpc-js": "^1.13.3",
"@grpc/proto-loader": "^0.7.15",
"@types/qs": "^6.9.18",
"axios": "1.13.6",
"axios": "1.16.0",
"debug": "^4.4.3",
"google-protobuf": "^4.0.0",
"grpc-js-reflection-client": "^1.3.0",
@@ -36185,6 +36358,7 @@
"version": "0.7.0",
"license": "MIT",
"dependencies": {
"@usebruno/common": "0.1.0",
"nanoid": "3.3.8",
"yup": "^0.32.11"
}
@@ -36216,7 +36390,7 @@
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"axios": "1.13.6",
"axios": "1.16.0",
"body-parser": "2.2.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
@@ -36484,4 +36658,4 @@
}
}
}
}
}

View File

@@ -23,7 +23,6 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.1",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -32,6 +31,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",
@@ -43,7 +43,7 @@
"globals": "^16.1.0",
"husky": "^9.1.7",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"lodash-es": "^4.17.23",
"nano-staged": "^0.8.0",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
@@ -80,7 +80,7 @@
"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",
@@ -94,9 +94,9 @@
]
},
"overrides": {
"axios":"1.13.6",
"axios": "1.16.0",
"rollup": "3.30.0",
"pbkdf2":"3.1.5",
"pbkdf2": "3.1.5",
"electron-store": {
"conf": {
"json-schema-typed": "8.0.1"

View File

@@ -1,3 +1,19 @@
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn()
}))
});
jest.mock('nanoid', () => {
return {
nanoid: () => {}

View File

@@ -86,7 +86,7 @@
"react-virtuoso": "^4.18.1",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"shell-quote": "^1.8.4",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "^5.31.0",

View File

@@ -38,6 +38,9 @@ export default defineConfig({
dynamicImportMode: "eager",
},
},
rules: [
{ test: /\.md$/, type: 'asset/source' }
]
},
ignoreWarnings: [
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')

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

@@ -152,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,7 @@
# What's New in Bruno
- Various stability and performance improvements.
---
For the full release history, see the [Bruno releases page](https://github.com/usebruno/bruno/releases).

View File

@@ -0,0 +1,31 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
.changelog-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid ${(props) => props.theme.requestTabs?.border || props.theme.sidebar?.border || 'transparent'};
color: ${(props) => props.theme.text};
.header-version {
font-size: ${(props) => props.theme.font?.size?.sm || '0.85em'};
color: ${(props) => props.theme.colors?.text?.muted || props.theme.text};
opacity: 0.7;
}
}
.changelog-body {
flex: 1;
overflow-y: auto;
padding: 1rem 1.5rem 2rem 1.5rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { IconConfetti } from '@tabler/icons';
import Markdown from 'components/MarkDown';
import { version } from '../../../package.json';
import changelogContent from './CHANGELOG.md';
import StyledWrapper from './StyledWrapper';
const ChangelogTab = () => {
return (
<StyledWrapper>
<div className="changelog-header">
<IconConfetti size={18} strokeWidth={1.5} />
<span>What's New</span>
<span className="header-version">v{version}</span>
</div>
<div className="changelog-body">
<Markdown content={changelogContent} onDoubleClick={() => {}} />
</div>
</StyledWrapper>
);
};
export default ChangelogTab;

View File

@@ -165,6 +165,32 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
}
@keyframes cm-error-line-flash {
0%, 60% {
background-color: ${(props) => props.theme.status.danger.background};
}
100% {
background-color: transparent;
}
}
.CodeMirror .cm-error-line-flash {
background-color: transparent;
animation: cm-error-line-flash 3s ease-in-out;
}
.CodeMirror .cm-error-line-flash-gutter {
color: ${(props) => props.theme.colors.text.danger} !important;
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
.CodeMirror .cm-error-line-flash {
animation: none;
background-color: ${(props) => props.theme.status.danger.background};
}
}
.cm-search-match {
background: rgba(255, 193, 7, 0.25);
}

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';
@@ -13,6 +14,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Script = ({ collection }) => {
const dispatch = useDispatch();
@@ -59,6 +61,20 @@ const Script = ({ collection }) => {
return () => clearTimeout(timer);
}, [activeTab]);
useFocusErrorLine({
uid: collection.uid,
editorRef: preRequestEditorRef,
scriptPhase: 'pre-request',
isVisible: activeTab === 'pre-request'
});
useFocusErrorLine({
uid: collection.uid,
editorRef: postResponseEditorRef,
scriptPhase: 'post-response',
isVisible: activeTab === 'post-response'
});
const onRequestScriptEdit = (value) => {
dispatch(
updateCollectionRequestScript({
@@ -108,39 +124,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,12 +2,14 @@ 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';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Tests = ({ collection }) => {
const dispatch = useDispatch();
@@ -29,24 +31,33 @@ const Tests = ({ collection }) => {
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
useFocusErrorLine({
uid: collection.uid,
editorRef: testsEditorRef,
scriptPhase: 'test'
});
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

@@ -5,6 +5,8 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import DataTypeSelector from 'components/DataTypeSelector';
import { valueToString } from '@usebruno/common/utils';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -57,15 +59,31 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
render: ({ row, value, onChange, isLastEmptyRow }) => (
<div className="flex items-center w-full gap-2">
<div className="flex-1 min-w-0">
<MultiLineEditor
value={valueToString(value)}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
</div>
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
{!isLastEmptyRow && varType === 'request' && (
<DataTypeSelector
variable={row}
theme={storedTheme}
collection={collection}
onChange={(fields) => {
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
handleVarsChange(updated);
}}
/>
)}
</div>
)
}
];
@@ -80,6 +98,7 @@ const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
<StyledWrapper className="w-full">
<EditableTable
tableId="collection-vars"
testId={`collection-vars-${varType === 'response' ? 'res' : 'req'}`}
columns={columns}
rows={vars}
onChange={handleVarsChange}

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

@@ -0,0 +1,19 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.type-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
font-size: 0.75rem;
opacity: 0.7;
}
.caret-icon {
opacity: 0.7;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { IconAlertCircle, IconCaretDown } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { BRUNO_VARIABLE_DATATYPES, parseValueByDataType, validateDataTypeValue } from '@usebruno/common/utils';
import MenuDropdown from 'ui/MenuDropdown';
import StyledWrapper from './StyledWrapper';
const DataTypeSelector = ({ variable, onChange }) => {
const selectedType = variable.dataType || 'string';
const coercedValue = parseValueByDataType(variable.value, selectedType);
const typeError = validateDataTypeValue(coercedValue, selectedType);
const handleTypeChange = (type) => {
onChange({ dataType: type === 'string' ? undefined : type });
};
const items = BRUNO_VARIABLE_DATATYPES.map((type) => ({
id: type,
label: type,
onClick: () => handleTypeChange(type)
}));
return (
<StyledWrapper>
<div className="flex items-center relative">
<MenuDropdown
items={items}
selectedItemId={selectedType}
placement="bottom-end"
showTickMark={true}
appendTo={() => document.body}
>
<div className="flex items-center cursor-pointer select-none">
<span className="type-label">{selectedType}</span>
<IconCaretDown className="caret-icon ml-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
{typeError && (
<span className="ml-1">
<IconAlertCircle
data-tooltip-id={`type-error-${variable.uid}`}
className="text-yellow-600 cursor-pointer"
size={16}
/>
<Tooltip
className="tooltip-mod"
id={`type-error-${variable.uid}`}
content={typeError}
place="top"
/>
</span>
)}
</div>
</StyledWrapper>
);
};
export default React.memo(DataTypeSelector);

View File

@@ -69,13 +69,22 @@ const StyledWrapper = styled.div`
height: 100%;
overflow: hidden;
min-height: 0; /* Important for proper flex behavior */
position: relative;
}
.col-separator {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: ${(props) => props.theme.console.border};
pointer-events: none;
z-index: 2;
}
.requests-header {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 4px 16px;
padding: 0;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 10px;
@@ -83,6 +92,39 @@ const StyledWrapper = styled.div`
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
.header-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
&:first-child {
padding-left: 16px;
}
&:last-child {
padding-right: 16px;
}
&:hover {
color: ${(props) => props.theme.console.messageColor};
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
svg {
flex-shrink: 0;
}
}
}
.requests-list {
@@ -94,9 +136,7 @@ const StyledWrapper = styled.div`
.request-row {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 2px 16px;
padding: 0;
cursor: pointer;
transition: background-color 0.1s ease;
font-size: ${(props) => props.theme.font.size.sm};
@@ -107,12 +147,19 @@ const StyledWrapper = styled.div`
}
&.selected {
padding-left: 13px;
background: ${(props) => props.theme.console.logHoverBg};
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor};
}
}
.request-method {
padding: 2px 8px 2px 16px;
}
.request-status {
padding: 2px 8px;
}
.method-badge {
display: inline-flex;
align-items: center;
@@ -128,6 +175,7 @@ const StyledWrapper = styled.div`
}
.request-domain {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
@@ -135,6 +183,7 @@ const StyledWrapper = styled.div`
}
.request-path {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
@@ -143,19 +192,26 @@ const StyledWrapper = styled.div`
}
.request-time {
padding: 2px 8px;
color: ${(props) => props.theme.console.timestampColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};
}
.request-duration {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};
text-align: right;
}
.text-right {
text-align: right;
}
.request-size {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};

View File

@@ -1,12 +1,26 @@
import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconNetwork
IconNetwork,
IconArrowUp,
IconArrowDown
} from '@tabler/icons';
import {
setSelectedRequest
} from 'providers/ReduxStore/slices/logs';
import StyledWrapper from './StyledWrapper';
import { getGridTemplate, getSeparatorPositions, sortRequests } from './utils';
// TODO: Columns will be resizable in the future, so width can be null (for auto) or a number (for fixed width)
const COLUMNS = [
{ key: 'method', label: 'Method', width: 90, align: 'left' },
{ key: 'status', label: 'Status', width: 80, align: 'left' },
{ key: 'domain', label: 'Domain', width: 200, align: 'left' },
{ key: 'path', label: 'Path', width: null, align: 'left' },
{ key: 'time', label: 'Time', width: 100, align: 'left' },
{ key: 'duration', label: 'Duration', width: 120, align: 'right' },
{ key: 'size', label: 'Size', width: 80, align: 'right' }
];
const MethodBadge = ({ method }) => {
const methodLower = method?.toLowerCase() || 'get';
@@ -28,7 +42,7 @@ const StatusBadge = ({ status, statusCode }) => {
);
};
const RequestRow = ({ request, isSelected, onClick }) => {
const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
const { data } = request;
const { request: req, response: res, timestamp } = data;
@@ -82,6 +96,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
<div
className={`request-row ${isSelected ? 'selected' : ''}`}
onClick={onClick}
style={{ gridTemplateColumns }}
data-testid="network-request-row"
>
<div className="request-method">
<MethodBadge method={req?.method} />
@@ -116,6 +133,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
const NetworkTab = () => {
const dispatch = useDispatch();
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []);
const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []);
const { networkFilters, selectedRequest } = useSelector((state) => state.logs);
const collections = useSelector((state) => state.collections.collections);
@@ -150,6 +170,21 @@ const NetworkTab = () => {
dispatch(setSelectedRequest(request));
};
const handleHeaderClick = (key) => {
setSortConfig((prev) => {
// If clicking a different column, start with ascending sort
if (prev.key !== key) return { key, direction: 'asc' };
if (prev.direction === 'asc') return { key, direction: 'desc' };
return { key: null, direction: null };
});
};
const sortedRequests = useMemo(
() => sortRequests(filteredRequests, sortConfig.key, sortConfig.direction),
[filteredRequests, sortConfig]
);
return (
<StyledWrapper>
<div className="network-content">
@@ -161,26 +196,45 @@ const NetworkTab = () => {
</div>
) : (
<div className="requests-container">
<div className="requests-header">
<div>Method</div>
<div>Status</div>
<div>Domain</div>
<div>Path</div>
<div>Time</div>
<div className="text-right">Duration</div>
<div className="text-right">Size</div>
<div className="requests-header" style={{ gridTemplateColumns }}>
{COLUMNS.map((col) => (
<div
key={col.key}
className={`header-cell${col.align === 'right' ? ' text-right' : ''}`}
onClick={() => handleHeaderClick(col.key)}
data-testid={`network-header-${col.key}`}
>
<span title={col.label}>{col.label}</span>
{sortConfig.key === col.key && (
sortConfig.direction === 'asc'
? <IconArrowUp size={14} strokeWidth={2} data-testid="sort-icon-asc" />
: <IconArrowDown size={14} strokeWidth={2} data-testid="sort-icon-desc" />
)}
</div>
))}
</div>
<div className="requests-list">
{filteredRequests.map((request, index) => (
{sortedRequests.map((request, index) => (
<RequestRow
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
request={request}
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
onClick={() => handleRequestClick(request)}
gridTemplateColumns={gridTemplateColumns}
/>
))}
</div>
{separatorPositions.map((pos, i) =>
pos ? (
<div
key={i}
className="col-separator"
style={'left' in pos ? { left: `${pos.left}px` } : { right: `${pos.right}px` }}
/>
) : null
)}
</div>
)}
</div>

View File

@@ -0,0 +1,179 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { ThemeProvider } from 'providers/Theme';
import NetworkTab from './index';
const makeRequest = (overrides = {}) => ({
type: 'request',
timestamp: overrides.timestamp ?? 1000,
collectionUid: overrides.collectionUid ?? 'col-1',
itemUid: overrides.itemUid ?? 'item-1',
collectionName: 'Test Collection',
data: {
request: {
method: overrides.method ?? 'GET',
url: overrides.url ?? 'https://example.com/api/users'
},
response: {
status: overrides.status ?? 200,
statusCode: overrides.statusCode ?? 200,
// Use 'in' check so callers can explicitly pass undefined to test missing-value behaviour
...('duration' in overrides ? { duration: overrides.duration } : { duration: 100 }),
...('size' in overrides ? { size: overrides.size } : { size: 512 })
},
timestamp: overrides.timestamp ?? 1000
}
});
const ALL_FILTERS = { GET: true, POST: true, PUT: true, DELETE: true, PATCH: true, HEAD: true, OPTIONS: true };
const renderNetworkTab = (requests = []) => {
const store = configureStore({
reducer: {
collections: (state = {
collections: [{
uid: 'col-1',
name: 'Test Collection',
timeline: requests
}]
}) => state,
logs: (state = {
networkFilters: ALL_FILTERS,
selectedRequest: null
}) => state
}
});
return render(
<Provider store={store}>
<ThemeProvider>
<NetworkTab />
</ThemeProvider>
</Provider>
);
};
describe('sort state cycle', () => {
const requests = [
makeRequest({ itemUid: 'a', method: 'GET' }),
makeRequest({ itemUid: 'b', method: 'POST' })
];
it('shows no sort icon by default', () => {
renderNetworkTab(requests);
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
});
it('first click on a column shows ascending icon', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
});
it('second click on same column shows descending icon', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
expect(screen.getByTestId('sort-icon-desc')).toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
});
it('third click on same column clears sort', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
expect(screen.queryByTestId('sort-icon-asc')).not.toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
});
it('clicking a different column resets to ascending on the new column', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method')); // now desc
fireEvent.click(screen.getByTestId('network-header-status')); // switch column
// Should show asc on status, not desc
expect(screen.getByTestId('sort-icon-asc')).toBeInTheDocument();
expect(screen.queryByTestId('sort-icon-desc')).not.toBeInTheDocument();
});
it('sort icon only appears on the active column', () => {
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-duration'));
// Only one icon total
expect(screen.getAllByTestId('sort-icon-asc')).toHaveLength(1);
});
});
describe('sort results', () => {
const getRowMethods = () =>
screen.getAllByTestId('network-request-row').map((row) =>
row.querySelector('.method-badge')?.textContent
);
it('sorts by method ascending (A → Z)', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'POST' }),
makeRequest({ itemUid: '2', method: 'GET' }),
makeRequest({ itemUid: '3', method: 'DELETE' })
];
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
});
it('sorts by method descending (Z → A)', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'POST' }),
makeRequest({ itemUid: '2', method: 'GET' }),
makeRequest({ itemUid: '3', method: 'DELETE' })
];
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
});
it('sorts by status ascending', () => {
const requests = [
makeRequest({ itemUid: '1', statusCode: 500 }),
makeRequest({ itemUid: '2', statusCode: 200 }),
makeRequest({ itemUid: '3', statusCode: 404 })
];
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-status'));
const rows = screen.getAllByTestId('network-request-row');
const statuses = rows.map((r) => r.querySelector('.status-badge')?.textContent);
expect(statuses).toEqual(['200', '404', '500']);
});
it('sorts mixed-case methods case-insensitively', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'post' }),
makeRequest({ itemUid: '2', method: 'GET' }),
makeRequest({ itemUid: '3', method: 'delete' })
];
renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method'));
// MethodBadge always renders uppercase; sort order should treat 'post' == 'POST'
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
});
it('preserves insertion order when sort is cleared', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'POST' }),
makeRequest({ itemUid: '2', method: 'GET' }),
makeRequest({ itemUid: '3', method: 'DELETE' })
];
renderNetworkTab(requests);
// Sort then clear
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
fireEvent.click(screen.getByTestId('network-header-method'));
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
});
});

View File

@@ -0,0 +1,57 @@
export const getGridTemplate = (columns) =>
columns.map((c) => (c.width ? `${c.width}px` : '1fr')).join(' ');
export const getSeparatorPositions = (columns) => {
const n = columns.length;
const positions = new Array(n - 1).fill(null);
let leftOffset = 0;
for (let i = 0; i < n - 1; i++) {
if (columns[i].width === null) break;
leftOffset += columns[i].width;
positions[i] = { left: leftOffset };
}
let rightOffset = 0;
for (let i = n - 1; i > 0; i--) {
if (columns[i].width === null) break;
rightOffset += columns[i].width;
if (positions[i - 1] === null) {
positions[i - 1] = { right: rightOffset };
}
}
return positions;
};
export const getSortValue = (request, key) => {
const { request: req, response: res, timestamp } = request.data;
switch (key) {
case 'method': return req?.method?.toUpperCase() ?? '';
case 'status': return res?.statusCode || res?.status || 0;
case 'domain': {
try { return new URL(req?.url || '').hostname; } catch { return req?.url || ''; }
}
case 'path': {
try {
const u = new URL(req?.url || '');
return u.pathname + u.search;
} catch { return req?.url || ''; }
}
case 'time': return timestamp || 0;
case 'duration': return res?.duration || 0;
case 'size': return res?.size || 0;
default: return '';
}
};
export const sortRequests = (requests, key, direction) => {
if (!key || !direction) return requests;
return [...requests].sort((a, b) => {
const valueA = getSortValue(a, key);
const valueB = getSortValue(b, key);
if (valueA < valueB) return direction === 'asc' ? -1 : 1;
if (valueA > valueB) return direction === 'asc' ? 1 : -1;
return 0;
});
};

View File

@@ -4,11 +4,8 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: ${(props) => props.theme.console.contentBg};
border-left: 1px solid ${(props) => props.theme.console.border};
min-width: 400px;
max-width: 600px;
width: 40%;
overflow: hidden;
.panel-header {

View File

@@ -144,6 +144,41 @@ const StyledWrapper = styled.div`
gap: 4px;
}
.details-panel-wrapper {
position: relative;
flex-shrink: 0;
height: 100%;
display: flex;
}
div.details-drag-handle {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
cursor: col-resize;
background-color: transparent;
width: 6px;
position: absolute;
left: -3px;
top: 0;
z-index: 10;
transition: opacity 0.2s ease;
div.drag-request-border {
width: 1px;
height: 100%;
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.border};
}
&:hover div.drag-request-border {
width: 1px;
height: 100%;
border-left: solid 1px ${(props) => props.theme.sidebar.dragbar.activeBorder};
}
}
.action-controls {
display: flex;
align-items: center;

View File

@@ -23,7 +23,8 @@ import {
setActiveTab,
clearDebugErrors,
updateNetworkFilter,
toggleAllNetworkFilters
toggleAllNetworkFilters,
updateRequestDetailsPanelWidth
} from 'providers/ReduxStore/slices/logs';
import NetworkTab from './NetworkTab';
@@ -33,6 +34,10 @@ import RequestDetailsPanel from './RequestDetailsPanel';
import ErrorDetailsPanel from './ErrorDetailsPanel';
import Performance from '../Performance';
import StyledWrapper from './StyledWrapper';
import { useResizablePanel } from 'hooks/useResizablePanel';
const MIN_DETAILS_PANEL_WIDTH = 280;
const MAX_DETAILS_PANEL_WIDTH = 800;
const LogIcon = ({ type }) => {
const iconProps = { size: 16, strokeWidth: 1.5 };
@@ -381,8 +386,17 @@ const Console = () => {
const dispatch = useDispatch();
const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector((state) => state.logs);
const collections = useSelector((state) => state.collections.collections);
const savedDetailsPanelWidth = useSelector((state) => state.logs.requestDetailsPanelWidth);
const consoleRef = useRef(null);
const { width: detailsPanelWidth, handleDragStart: handleDetailsPanelDragStart } = useResizablePanel({
initialWidth: savedDetailsPanelWidth,
minWidth: MIN_DETAILS_PANEL_WIDTH,
maxWidth: MAX_DETAILS_PANEL_WIDTH,
direction: 'right',
onResizeEnd: (newWidth) => dispatch(updateRequestDetailsPanelWidth({ requestDetailsPanelWidth: newWidth }))
});
const logCounts = logs.reduce((counts, log) => {
counts[log.type] = (counts[log.type] || 0) + 1;
return counts;
@@ -614,7 +628,16 @@ const Console = () => {
<div className="network-main">
{renderTabContent()}
</div>
<RequestDetailsPanel />
<div className="details-panel-wrapper" style={{ width: detailsPanelWidth }}>
<div
className="details-drag-handle"
onMouseDown={handleDetailsPanelDragStart}
data-testid="details-panel-drag-handle"
>
<div className="drag-request-border" />
</div>
<RequestDetailsPanel />
</div>
</div>
) : activeTab === 'debug' && selectedError ? (
<div className="debug-with-details">

View File

@@ -21,17 +21,19 @@ const findScrollParent = (element) => {
const TableRow = React.memo(
({ children, item, context, ...rest }) => {
const rowIndex = Number(rest['data-item-index']);
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave, keyColumn } = context;
const isEmpty = isLastEmptyRow(item, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
const isDragOver = canDrag && dragOverRow === rowIndex;
const existingClass = rest.className || '';
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
const rowName = keyColumn ? item?.[keyColumn.key] : undefined;
return (
<tr
{...rest}
className={className}
data-row-name={rowName || undefined}
draggable={canDrag}
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
@@ -168,6 +170,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 +190,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 +206,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 +231,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);
@@ -370,17 +344,20 @@ const EditableTable = ({
);
}, [isLastEmptyRow, getRowError, handleValueChange]);
const keyColumn = useMemo(() => columns.find((col) => col.isKeyField), [columns]);
const virtuosoContext = useMemo(() => ({
reorderable,
reorderableRowCount,
isLastEmptyRow,
dragOverRow,
keyColumn,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
onDragEnd: handleDragEnd
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, keyColumn, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
const fixedHeaderContent = useCallback(() => (
<tr>

View File

@@ -1,15 +1,17 @@
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector, useDispatch } from 'react-redux';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor/index';
import DataTypeSelector from 'components/DataTypeSelector';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { BRUNO_VARIABLE_DATATYPES, valueToString } from '@usebruno/common/utils';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
@@ -23,14 +25,17 @@ const MIN_COLUMN_WIDTH = 80;
const MIN_ROW_HEIGHT = 35;
const TableRow = React.memo(
({ children, item, style, ...rest }) => (
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
{children}
</tr>
),
({ children, item, style, ...rest }) => {
const variable = item?.variable ?? item;
return (
<tr key={variable?.uid} style={style} {...rest} data-testid={`env-var-row-${variable?.name}`}>
{children}
</tr>
);
},
(prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
const prevUid = prevProps?.item?.variable?.uid ?? prevProps?.item?.uid;
const nextUid = nextProps?.item?.variable?.uid ?? nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
}
);
@@ -203,7 +208,9 @@ const EnvironmentVariablesTable = ({
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
value: Yup.mixed().nullable(),
dataType: Yup.string().oneOf(BRUNO_VARIABLE_DATATYPES).nullable(),
annotations: Yup.array().nullable()
})
),
validate: (values) => {
@@ -391,8 +398,16 @@ const EnvironmentVariablesTable = ({
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
// Compare without UIDs since they can be different but the actual data is the same
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
// Compare against what's on disk: for an ephemeral overlay, that's
// `persistedValue`, not the scripted value Redux is holding.
const baselineForCompare = (v) => {
const stripped = stripEnvVarUid(v);
if (v?.ephemeral && v?.persistedValue !== undefined) {
stripped.value = v.persistedValue;
}
return stripped;
};
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(baselineForCompare));
if (!hasChanges) {
toast.error('No changes to save');
return;
@@ -524,6 +539,7 @@ const EnvironmentVariablesTable = ({
<td></td>
</tr>
)}
defaultItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
@@ -569,21 +585,20 @@ const EnvironmentVariablesTable = ({
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
className="flex flex-row flex-nowrap items-center gap-2"
style={{ width: columnWidths.value }}
>
<div
className="overflow-hidden grow w-full relative"
className="flex-1 min-w-0 relative"
onFocus={() => handleRowFocus(variable.uid)}
>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
value={valueToString(variable.value, 2)}
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
@@ -608,13 +623,17 @@ const EnvironmentVariablesTable = ({
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
{!isLastEmptyRow && (
<span>
<DataTypeSelector
variable={variable}
theme={storedTheme}
collection={_collection}
onChange={(fields) => {
Object.entries(fields).forEach(([key, val]) => {
formik.setFieldValue(`${actualIndex}.${key}`, val, true);
});
}}
/>
</span>
)}

View File

@@ -25,7 +25,7 @@ const EnvironmentListContent = ({
<span>No Environment</span>
</div>
<ToolHint
anchorSelect="[data-tooltip-content]"
tooltipId="environment-name-tooltip"
place="right"
positionStrategy="fixed"
tooltipStyle={{
@@ -40,6 +40,7 @@ const EnvironmentListContent = ({
key={env.uid}
className={`dropdown-item ${env.uid === activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
onClick={() => onEnvironmentSelect(env)}
data-tooltip-id="environment-name-tooltip"
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>

View File

@@ -4,6 +4,8 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.danger};
pre {
color: ${(props) => props.theme.colors.danger};
max-height: 60vh;
overflow: auto;
}
`;

View File

@@ -0,0 +1,12 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.danger};
pre {
display: block;
overflow-wrap: anywhere;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { useState } from 'react';
import StyledWrapper from './StyledWrapper';
const SaveFileErrorModal = ({ error }) => {
const [showModal, setShowModal] = useState(true);
return (
<>
{showModal ? (
<Portal>
<StyledWrapper>
<Modal
size="sm"
title="Save File Error"
hideFooter={true}
hideCancel={true}
handleCancel={() => {
setShowModal(false);
}}
disableCloseOnOutsideClick={true}
disableEscapeKey={true}
>
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
</Modal>
</StyledWrapper>
</Portal>
) : null}
</>
);
};
export default SaveFileErrorModal;

View File

@@ -0,0 +1,55 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
height: 100%;
background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border};
font-family: ${(props) => (props.font ? props.font : 'default')};
line-break: anywhere;
}
.CodeMirror-overlayscroll-horizontal div,
.CodeMirror-overlayscroll-vertical div {
background: #d2d7db;
}
textarea.cm-editor {
position: relative;
}
// Todo: dark mode temporary fix
// Clean this
.CodeMirror.cm-s-monokai {
.CodeMirror-overlayscroll-horizontal div,
.CodeMirror-overlayscroll-vertical div {
background: #444444;
}
}
.cm-s-monokai span.cm-property,
.cm-s-monokai span.cm-attribute {
color: #9cdcfe !important;
}
.cm-s-monokai span.cm-string {
color: #ce9178 !important;
}
.cm-s-monokai span.cm-number {
color: #b5cea8 !important;
}
.cm-s-monokai span.cm-atom {
color: #569cd6 !important;
}
.cm-variable-valid {
color: green;
}
.cm-variable-invalid {
color: red;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,236 @@
/**
* Copyright (c) 2021 GraphQL Contributors.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React from 'react';
import isEqual from 'lodash/isEqual';
import { getEnvironmentVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import StyledWrapper from './StyledWrapper';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import CodeMirrorSearch from 'components/CodeMirrorSearch';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
window.JSHINT = JSHINT;
}
export default class CodeEditor extends React.Component {
constructor(props) {
super(props);
// Keep a cached version of the value, this cache will be updated when the
// editor is updated, which can later be used to protect the editor from
// unnecessary updates during the update lifecycle.
this.cachedValue = props.value || '';
this.variables = {};
this.lintOptions = {
esversion: 11,
expr: true,
asi: true
};
this.state = {
searchBarVisible: false
};
}
componentDidMount() {
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
lineWrapping: true,
tabSize: 2,
mode: this.props.mode || 'application/ld+json',
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
showCursorWhenSelecting: true,
foldGutter: true,
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter', 'CodeMirror-lint-markers'],
lint: this.lintOptions,
readOnly: this.props.readOnly,
scrollbarStyle: 'overlay',
theme: this.props.theme === 'dark' ? 'monokai' : 'default',
extraKeys: {
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Ctrl-S': () => {
if (this.props.onSave) {
this.props.onSave();
}
},
'Shift-Cmd-M': () => {
if (this.props.toggleFileMode) {
this.props.toggleFileMode();
}
},
'Shift-Ctrl-M': () => {
if (this.props.toggleFileMode) {
this.props.toggleFileMode();
}
},
'Cmd-F': (cm) => {
if (this.state.searchBarVisible) {
this._node.querySelector('.bruno-search-bar > input').focus();
}
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
},
'Ctrl-F': (cm) => {
if (this.state.searchBarVisible) {
this._node.querySelector('.bruno-search-bar > input').focus();
}
if (!this.state.searchBarVisible) {
this.setState({ searchBarVisible: true });
}
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
'Tab': function (cm) {
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
? cm.execCommand('indentMore')
: cm.replaceSelection(' ', 'end');
},
'Shift-Tab': 'indentLess',
'Ctrl-Space': 'autocomplete',
'Cmd-Space': 'autocomplete',
'Ctrl-Y': 'foldAll',
'Cmd-Y': 'foldAll',
'Ctrl-I': 'unfoldAll',
'Cmd-I': 'unfoldAll',
'Esc': () => {
if (this.state.searchBarVisible) {
this.setState({ searchBarVisible: false });
}
}
}
}));
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
editor.scrollTo(null, this.props.initialScroll);
this._lastScrollTop = this.props.initialScroll || 0;
editor.on('scroll', this._onScroll);
this.addOverlay();
}
}
componentDidUpdate(prevProps) {
// Ensure the changes caused by this update are not interpreted as
// user-input changes which could otherwise result in an infinite
// event loop.
this.ignoreChangeEvent = true;
if (this.props.schema !== prevProps.schema && this.editor) {
this.editor.options.lint.schema = this.props.schema;
this.editor.options.hintOptions.schema = this.props.schema;
this.editor.options.info.schema = this.props.schema;
this.editor.options.jump.schema = this.props.schema;
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
this.editor.setCursor(cursor);
}
if (this.editor) {
let variables = getEnvironmentVariables(this.props.collection);
if (!isEqual(variables, this.variables)) {
this.addOverlay();
}
}
if (this.props.theme !== prevProps.theme && this.editor) {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.initialScroll !== prevProps.initialScroll && this.editor) {
this.editor.scrollTo(null, this.props.initialScroll);
}
this.ignoreChangeEvent = false;
}
componentWillUnmount() {
if (this.editor) {
this.editor.off('change', this._onEdit);
this.editor.off('scroll', this._onScroll);
if (typeof this.props.onScroll === 'function') {
this.props.onScroll(this._lastScrollTop || 0);
}
const editorElement = this.editor.getWrapperElement();
if (editorElement && editorElement.parentNode) {
editorElement.parentNode.removeChild(editorElement);
}
this.editor = null;
this._node = null;
}
}
render() {
if (this.editor) {
this.editor.refresh();
}
return (
<StyledWrapper
className="h-full w-full"
aria-label="Code Editor"
font={this.props.font}
>
<CodeMirrorSearch
visible={this.state.searchBarVisible}
editor={this.editor}
onClose={() => this.setState({ searchBarVisible: false })}
/>
<div
ref={(node) => {
this._node = node;
}}
style={{ height: '100%' }}
/>
</StyledWrapper>
);
}
addOverlay = () => {
const mode = this.props.mode || 'application/ld+json';
let variables = getEnvironmentVariables(this.props.collection);
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, mode);
this.editor.setOption('mode', 'brunovariables');
};
_onEdit = () => {
if (!this.ignoreChangeEvent && this.editor) {
this.editor.setOption('lint', this.editor.getValue().trim().length > 0 ? this.lintOptions : false);
this.cachedValue = this.editor.getValue();
if (this.props.onEdit) {
this.props.onEdit(this.cachedValue);
}
}
};
_onScroll = () => {
if (!this.editor) return;
const wrapper = this.editor.getWrapperElement();
if (wrapper && wrapper.offsetParent === null) return;
this._lastScrollTop = this.editor.getScrollInfo().top;
if (typeof this.props.onScroll === 'function') {
this.props.onScroll(this._lastScrollTop);
}
};
}

View File

@@ -0,0 +1,68 @@
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from './CodeEditor/index';
import { saveFile } from 'providers/ReduxStore/slices/collections/actions';
import { IconDeviceFloppy } from '@tabler/icons';
import { toggleCollectionFileMode, updateFileContent } from 'providers/ReduxStore/slices/collections';
import { usePersistedState } from 'hooks/usePersistedState';
const FileEditor = ({ item, collection }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [scroll, setScroll] = usePersistedState({ key: `file-mode-scroll-${item.uid}`, default: 0 });
const content = item.draft ? item.draft.raw : item.raw || '';
const onEdit = (value) => {
dispatch(
updateFileContent({
content: value,
itemUid: item.uid,
collectionUid: collection.uid
})
);
};
const hasChanges = item.draft != null;
const onSave = () => {
if (!hasChanges) return;
dispatch(saveFile(content, item?.uid, collection?.uid));
};
const _toggleFileMode = () => {
dispatch(toggleCollectionFileMode({ collectionUid: collection.uid }));
};
const editorMode = item?.type == 'js' ? 'javascript' : item?.type == 'json' ? 'javascript' : 'application/text';
return (
<div className="flex flex-grow relative h-full">
<CodeEditor
collection={collection}
theme={displayedTheme}
value={content}
onEdit={onEdit}
onSave={onSave}
toggleFileMode={_toggleFileMode}
mode={editorMode}
font={get(preferences, 'font.codeFont', 'default')}
initialScroll={scroll}
onScroll={setScroll}
/>
<IconDeviceFloppy
onClick={onSave}
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
}`}
/>
</div>
);
};
export default FileEditor;

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';
@@ -13,6 +14,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -22,7 +24,9 @@ const Script = ({ collection, folder }) => {
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
const focusedTab = find(tabs, (tab) => tab.type === 'folder-settings' && (tab.uid === folder.uid || tab.folderUid === folder.uid))
|| find(tabs, (tab) => tab.type === 'folder-settings' && tab.pathname === folder.pathname);
const tabUid = focusedTab?.uid || folder.uid;
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
@@ -34,7 +38,7 @@ const Script = ({ collection, folder }) => {
const activeTab = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
dispatch(updateScriptPaneTab({ uid: tabUid, scriptPaneTab: tab }));
};
const { displayedTheme } = useTheme();
@@ -60,6 +64,20 @@ const Script = ({ collection, folder }) => {
return () => clearTimeout(timer);
}, [activeTab]);
useFocusErrorLine({
uid: folder.uid,
editorRef: preRequestEditorRef,
scriptPhase: 'pre-request',
isVisible: activeTab === 'pre-request'
});
useFocusErrorLine({
uid: folder.uid,
editorRef: postResponseEditorRef,
scriptPhase: 'post-response',
isVisible: activeTab === 'post-response'
});
const onRequestScriptEdit = (value) => {
dispatch(
updateFolderRequestScript({
@@ -111,39 +129,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,12 +2,14 @@ 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';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Tests = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -30,24 +32,33 @@ const Tests = ({ collection, folder }) => {
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
useFocusErrorLine({
uid: folder.uid,
editorRef: testsEditorRef,
scriptPhase: 'test'
});
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

@@ -5,6 +5,8 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import DataTypeSelector from 'components/DataTypeSelector';
import { valueToString } from '@usebruno/common/utils';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -62,16 +64,32 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
item={folder}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
render: ({ row, value, onChange, isLastEmptyRow }) => (
<div className="flex items-center w-full gap-2">
<div className="flex-1 min-w-0">
<MultiLineEditor
value={valueToString(value)}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
collection={collection}
item={folder}
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
</div>
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
{!isLastEmptyRow && varType === 'request' && (
<DataTypeSelector
variable={row}
theme={storedTheme}
collection={collection}
onChange={(fields) => {
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
handleVarsChange(updated);
}}
/>
)}
</div>
)
}
];
@@ -86,6 +104,7 @@ const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) =>
<StyledWrapper className="w-full">
<EditableTable
tableId="folder-vars"
testId={`folder-vars-${varType === 'response' ? 'res' : 'req'}`}
columns={columns}
rows={vars}
onChange={handleVarsChange}

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

@@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
import { IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import useFocusTrap from 'hooks/useFocusTrap';
import Button from 'ui/Button';
@@ -12,13 +13,15 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
{handleCancel && !hideClose ? (
// TODO: Remove data-test-id and use data-testid instead across the codebase.
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-testid="modal-close-button">
×
<IconX size={16} strokeWidth={1.5} />
</div>
) : null}
</div>
);
const ModalContent = ({ children }) => <div className="bruno-modal-content px-4 py-4">{children}</div>;
const ModalContent = ({ children, noPadding }) => (
<div className={`bruno-modal-content ${noPadding ? '' : 'px-4 py-4'}`}>{children}</div>
);
const ModalFooter = ({
confirmText,
@@ -28,6 +31,7 @@ const ModalFooter = ({
confirmDisabled,
hideCancel,
hideFooter,
footerLeft,
confirmButtonColor = 'primary',
dataTestId = 'modal'
}) => {
@@ -39,24 +43,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"
data-testid={`${dataTestId}-submit-btn`}
>
{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>
);
};
@@ -74,12 +81,14 @@ const Modal = ({
hideCancel,
hideFooter,
hideClose,
footerLeft,
disableCloseOnOutsideClick,
disableEscapeKey,
onClick,
closeModalFadeTimeout = 500,
dataTestId,
confirmButtonColor = 'primary'
confirmButtonColor = 'primary',
noPadding
}) => {
const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false);
@@ -143,7 +152,7 @@ const Modal = ({
handleCancel={() => closeModal({ type: 'icon' })}
customHeader={customHeader}
/>
<ModalContent>{children}</ModalContent>
<ModalContent noPadding={noPadding}>{children}</ModalContent>
<ModalFooter
confirmText={confirmText}
cancelText={cancelText}
@@ -152,6 +161,7 @@ const Modal = ({
confirmDisabled={confirmDisabled}
hideCancel={hideCancel}
hideFooter={hideFooter}
footerLeft={footerLeft}
confirmButtonColor={confirmButtonColor}
dataTestId={dataTestId}
/>

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

@@ -0,0 +1,95 @@
import DOMPurify from 'dompurify';
import { parseToRgb, rgba } from 'polished';
import { useTheme } from 'providers/Theme';
import { humanizeDate } from 'utils/common';
// color may be any CSS color (hex, rgb, hsl): solid text on a 15% tinted bg.
// Falls back to the theme's purple when the supplied color can't be parsed.
export const getBadgeStyle = (color, theme) => {
let badgeColor = theme.colors.text.purple;
try {
parseToRgb(color);
badgeColor = color;
} catch {
// invalid color; keep the fallback
}
return {
backgroundColor: rgba(badgeColor, 0.15),
color: badgeColor
};
};
const getSanitizedDescription = (description) => {
return DOMPurify.sanitize(description || '', {
ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'br', 'strong', 'em'],
ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
});
};
const NotificationDetail = ({ notification }) => {
const { theme } = useTheme();
// Rendered in a sandboxed iframe (no allow-scripts); theme CSS is inlined
// since the iframe doesn't inherit app styles.
const buildDescriptionDocument = (description) => {
const body = getSanitizedDescription(description);
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base target="_blank" />
<style>
html, body { margin: 0; padding: 0; background: ${theme.notifications.bg}; }
body {
padding: 8px 12px;
font-family: Inter, sans-serif;
font-size: 12px;
line-height: 20px;
font-weight: 500;
color: ${theme.colors.text.muted};
word-break: break-word;
}
p { margin: 0 0 0.75rem 0; }
a { color: ${theme.textLink}; text-decoration: underline; }
h1, h2, h3, h4, h5, h6 { font-size: 13px; font-weight: 600; margin: 0 0 0.5rem 0; color: ${theme.text}; }
ul { padding-left: 1.25rem; margin: 0 0 0.75rem 0; }
img { max-width: 100%; }
</style>
</head>
<body>${body}</body>
</html>`;
};
if (!notification) {
return (
<div className="notif-detail">
<div className="notif-empty">Select a notification to read more.</div>
</div>
);
}
return (
<div className="notif-detail">
<div className="notif-detail-header">
<div className="notif-detail-meta">
{notification.type && (
<span className="notif-type-badge" style={getBadgeStyle(notification.color, theme)}>
{notification.type}
</span>
)}
<span className="notif-detail-date">{humanizeDate(notification.date)}</span>
</div>
<div className="notif-detail-title">{notification.title}</div>
</div>
<iframe
key={notification.id}
className="notif-detail-body"
title="Notification details"
sandbox="allow-popups"
srcDoc={buildDescriptionDocument(notification.description)}
/>
</div>
);
};
export default NotificationDetail;

View File

@@ -0,0 +1,40 @@
import { rgba } from 'polished';
import { getBadgeStyle } from './NotificationDetail';
describe('getBadgeStyle', () => {
const theme = { colors: { text: { purple: '#8e44ad' } } };
it('uses a valid hex color for both text and tinted background', () => {
const style = getBadgeStyle('#ff0000', theme);
expect(style).toEqual({
backgroundColor: rgba('#ff0000', 0.15),
color: '#ff0000'
});
});
it('accepts rgb color strings', () => {
const style = getBadgeStyle('rgb(0, 128, 255)', theme);
expect(style.color).toBe('rgb(0, 128, 255)');
expect(style.backgroundColor).toBe(rgba('rgb(0, 128, 255)', 0.15));
});
it('accepts hsl color strings', () => {
const style = getBadgeStyle('hsl(210, 100%, 50%)', theme);
expect(style.color).toBe('hsl(210, 100%, 50%)');
expect(style.backgroundColor).toBe(rgba('hsl(210, 100%, 50%)', 0.15));
});
it('falls back to the theme purple for an unparseable color', () => {
const style = getBadgeStyle('not-a-color', theme);
expect(style).toEqual({
backgroundColor: rgba(theme.colors.text.purple, 0.15),
color: theme.colors.text.purple
});
});
it('falls back to the theme purple when color is undefined', () => {
const style = getBadgeStyle(undefined, theme);
expect(style.color).toBe(theme.colors.text.purple);
expect(style.backgroundColor).toBe(rgba(theme.colors.text.purple, 0.15));
});
});

View File

@@ -0,0 +1,35 @@
import classnames from 'classnames';
import { relativeDate } from 'utils/common';
const NotificationList = ({ items, selectedId, onSelect }) => {
return (
<ul className="notif-list">
{items.map((notification) => {
const isActive = selectedId === notification.id;
const isUnread = !notification.read;
return (
<li
key={notification.id}
className={classnames('notif-list-item', { active: isActive, unread: isUnread })}
role="button"
tabIndex={0}
onClick={() => onSelect(notification)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onSelect(notification);
}
}}
>
<div className={classnames('notif-item-title', { unread: isUnread })}>{notification.title}</div>
<div className="notif-item-date">{relativeDate(notification.date)}</div>
</li>
);
})}
{items.length === 0 && <li className="notif-list-empty">No notifications to show.</li>}
</ul>
);
};
export default NotificationList;

View File

@@ -0,0 +1,74 @@
import classnames from 'classnames';
import { IconDotsVertical } from '@tabler/icons';
import { useEffect, useRef } from 'react';
import Dropdown from 'components/Dropdown';
import { TABS } from '../hooks/useNotifications';
const menuIcon = (
<span className="notif-menu-trigger" aria-label="Notifications menu">
<IconDotsVertical size={16} strokeWidth={1.5} />
</span>
);
const NotificationTabs = ({ activeTab, unreadCount, onTabChange, onMarkAllRead, onClearAll }) => {
const dropdownTippyRef = useRef(null);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const hideDropdown = () => dropdownTippyRef.current?.hide();
// Clicks inside the detail iframe don't bubble to the parent document, so
// tippy's outside-click dismissal never fires. Closing on iframe focus covers it.
useEffect(() => {
const onWindowBlur = () => {
if (document.activeElement?.tagName === 'IFRAME') {
hideDropdown();
}
};
window.addEventListener('blur', onWindowBlur);
return () => window.removeEventListener('blur', onWindowBlur);
}, []);
return (
<div className="notif-tabs">
<div className="notif-tab-group">
<button
type="button"
className={classnames('notif-tab', { active: activeTab === TABS.ALL })}
onClick={() => onTabChange(TABS.ALL)}
>
All
</button>
<button
type="button"
className={classnames('notif-tab', { active: activeTab === TABS.UNREAD })}
onClick={() => onTabChange(TABS.UNREAD)}
>
Unread
{unreadCount > 0 && <span className="notif-tab-badge">{unreadCount}</span>}
</button>
</div>
<Dropdown icon={menuIcon} placement="bottom-end" onCreate={onDropdownCreate}>
<div
className={classnames('dropdown-item', { disabled: unreadCount === 0 })}
onClick={() => {
if (unreadCount === 0) return;
hideDropdown();
onMarkAllRead();
}}
>
Mark all as read
</div>
<div
className="dropdown-item"
onClick={() => {
hideDropdown();
onClearAll();
}}
>
Clear all
</div>
</Dropdown>
</div>
);
};
export default NotificationTabs;

View File

@@ -0,0 +1,267 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
display: flex;
flex-direction: row;
width: 800px;
height: 520px;
max-width: 100%;
max-height: 70vh;
overflow: hidden;
background-color: ${(props) => props.theme.notifications.bg};
/* While dragging, stop the detail iframe from swallowing mousemove events,
which would otherwise freeze the resize until the cursor re-enters the handle. */
&.dragging .notif-detail-body {
pointer-events: none;
}
.notif-sidebar {
flex: 0 0 auto;
display: flex;
flex-direction: column;
background-color: ${(props) => props.theme.notifications.list.bg};
}
.notif-resize-handle {
flex: 0 0 1px;
cursor: col-resize;
background: ${(props) => props.theme.notifications.list.borderBottom};
position: relative;
user-select: none;
transition: background-color 0.15s ease;
/* widen the hit target without bloating the visible line */
&::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -3px;
right: -3px;
}
&:hover,
&.dragging {
background: ${(props) => props.theme.colors.text.yellow};
}
}
.notif-tabs {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 12px;
gap: 6px;
border-bottom: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
}
.notif-tab-group {
display: flex;
align-items: center;
gap: 6px;
}
.notif-tab {
height: 24px;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
font-size: 12px;
line-height: 20px;
font-weight: 400;
color: ${(props) => props.theme.text};
background: transparent;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
&.active {
background-color: ${(props) => props.theme.brand};
color: ${(props) => props.theme.background.base};
font-weight: 500;
.notif-tab-badge {
background-color: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.brand};
border-color: ${(props) => props.theme.background.base};
}
}
}
.notif-tab-badge {
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
background-color: ${(props) => rgba(props.theme.brand, 0.1)};
color: ${(props) => props.theme.brand};
font-size: 11px;
line-height: 14px;
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
}
.notif-menu-trigger {
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 4px;
&:hover {
background-color: ${(props) => props.theme.notifications.list.hoverBg};
}
}
.notif-list {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
flex: 1;
min-height: 0;
background-color: ${(props) => props.theme.notifications.list.bg};
}
.notif-list-empty {
padding: 16px 12px;
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
font-style: italic;
text-align: center;
}
.notif-list-item {
position: relative;
padding: 8px 12px;
cursor: pointer;
border-bottom: solid 1px ${(props) => props.theme.notifications.list.borderBottom};
display: flex;
flex-direction: column;
gap: 0;
&:hover {
background-color: ${(props) => props.theme.notifications.list.hoverBg};
}
&.unread {
background-color: ${(props) => props.theme.notifications.list.active.bg};
&:hover {
background-color: ${(props) => props.theme.notifications.list.hoverBg};
}
}
&.active {
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background-color: ${(props) => props.theme.colors.text.yellow};
}
}
}
.notif-item-title {
color: ${(props) => props.theme.text};
font-size: 13px;
line-height: 20px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
&.unread {
font-weight: 600;
}
}
.notif-item-date,
.notif-detail-date {
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
line-height: 20px;
font-weight: 500;
}
.notif-detail {
flex: 1;
min-width: 0;
padding: 6px 6px 0 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.notif-detail-header {
display: flex;
flex-direction: column;
gap: 10px;
padding: 0 12px;
}
.notif-detail-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 1px;
min-height: 24px;
}
.notif-type-badge {
height: 24px;
padding: 4px 8px;
border-radius: 6px;
border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
font-size: 12px;
line-height: 20px;
font-weight: 400;
display: inline-flex;
align-items: center;
}
.notif-detail-title {
color: ${(props) => props.theme.text};
font-size: 13px;
line-height: 20px;
font-weight: 600;
}
.notif-detail-body {
flex: 1;
min-height: 0;
width: 100%;
border: none;
background: transparent;
}
.notif-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: ${(props) => props.theme.colors.text.muted};
font-size: 13px;
}
.notif-empty-text {
font-style: italic;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,89 @@
import { useRef } from 'react';
import classnames from 'classnames';
import { useDragResize } from 'hooks/useDragResize';
import { usePersistedState } from 'hooks/usePersistedState';
import Modal from 'components/Modal/index';
import Portal from 'components/Portal';
import StyledWrapper from './StyledWrapper';
import NotificationTabs from './NotificationTabs';
import NotificationList from './NotificationList';
import NotificationDetail from './NotificationDetail';
const DEFAULT_SIDEBAR_WIDTH = 260;
const SIDEBAR_MIN = 200;
// Reserved for the detail pane; caps the sidebar at ~420px in the 800px modal.
const DETAIL_MIN = 380;
const NotificationsModal = ({ notifications, onClose }) => {
const {
visibleNotifications,
listed,
unreadCount,
activeTab,
selectedNotification,
onTabChange,
onSelect,
onMarkAllRead,
onClearAll
} = notifications;
const containerRef = useRef(null);
const [sidebarWidth, setSidebarWidth] = usePersistedState({
key: 'notification-sidebar',
default: DEFAULT_SIDEBAR_WIDTH
});
const { dragging, dragWidth, dragbarProps } = useDragResize({
containerRef,
width: sidebarWidth,
onWidthChange: (w) => setSidebarWidth(w ?? DEFAULT_SIDEBAR_WIDTH),
minLeft: SIDEBAR_MIN,
minRight: DETAIL_MIN
});
const effectiveWidth = dragging ? dragWidth : sidebarWidth;
const isEmpty = visibleNotifications.length === 0;
return (
<Portal>
<Modal
size="md"
title="Notifications"
confirmText="Close"
handleConfirm={onClose}
handleCancel={onClose}
hideFooter={true}
disableCloseOnOutsideClick={true}
disableEscapeKey={true}
noPadding={true}
>
<StyledWrapper className={classnames('notifications-modal', { dragging })} ref={containerRef}>
<div className="notif-sidebar" style={{ width: effectiveWidth, flexBasis: effectiveWidth }}>
<NotificationTabs
activeTab={activeTab}
unreadCount={unreadCount}
onTabChange={onTabChange}
onMarkAllRead={onMarkAllRead}
onClearAll={onClearAll}
/>
<NotificationList items={listed} selectedId={selectedNotification?.id} onSelect={onSelect} />
</div>
<div
className={classnames('notif-resize-handle', { dragging })}
{...dragbarProps}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
/>
{isEmpty ? (
<div className="notif-empty">
<div className="notif-empty-text">You are all caught up!</div>
</div>
) : (
<NotificationDetail notification={selectedNotification} />
)}
</StyledWrapper>
</Modal>
</Portal>
);
};
export default NotificationsModal;

View File

@@ -1,85 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.notifications-modal {
margin-inline: -1rem;
margin-block: -1.5rem;
background-color: ${(props) => props.theme.notifications.bg};
}
.notification-count {
display: flex;
color: white;
position: absolute;
top: -0.625rem;
right: -0.5rem;
margin-right: 0.5rem;
justify-content: center;
font-size: 0.625rem;
border-radius: 50%;
background-color: ${(props) => props.theme.colors.text.yellow};
border: solid 2px ${(props) => props.theme.sidebar.bg};
min-width: 1.25rem;
}
button.mark-as-read {
font-weight: 400 !important;
}
ul.notifications {
background-color: ${(props) => props.theme.notifications.list.bg};
border-right: solid 1px ${(props) => props.theme.notifications.list.borderRight};
min-height: 400px;
height: 100%;
max-height: 85vh;
overflow-y: auto;
li {
min-width: 150px;
cursor: pointer;
padding: 0.5rem 0.625rem;
border-left: solid 2px transparent;
color: ${(props) => props.theme.textLink};
border-bottom: solid 1px ${(props) => props.theme.notifications.list.borderBottom};
&:hover {
background-color: ${(props) => props.theme.notifications.list.hoverBg};
}
&.active {
color: ${(props) => props.theme.text} !important;
background-color: ${(props) => props.theme.notifications.list.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.notifications.list.active.border};
&:hover {
background-color: ${(props) => props.theme.notifications.list.active.hoverBg} !important;
}
}
&.read {
color: ${(props) => props.theme.text} !important;
}
.notification-date {
font-size: ${(props) => props.theme.font.size.xs};
}
}
}
.notification-title {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.notification-date {
color: ${(props) => props.theme.colors.text.muted};
}
.pagination {
background-color: ${(props) => props.theme.notifications.list.bg};
border-right: solid 1px ${(props) => props.theme.notifications.list.borderRight};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,32 @@
import styled from 'styled-components';
const StyledWrapper = styled.button`
position: relative;
cursor: pointer;
background: none;
border: none;
padding: 0;
.notification-count {
position: absolute;
top: -4px;
right: -6px;
display: flex;
align-items: center;
justify-content: center;
min-width: 14px;
height: 14px;
padding: 0 3px;
color: ${(props) => props.theme.background.base};
font-size: 9px;
font-weight: 600;
line-height: 1;
border-radius: 999px;
background-color: ${(props) => props.theme.brand};
border: 1.5px solid ${(props) => props.theme.sidebar.bg};
box-sizing: border-box;
pointer-events: none;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,100 @@
import { useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
clearAllNotifications,
markAllNotificationsAsRead,
markNotificationAsRead
} from 'providers/ReduxStore/slices/notifications';
export const TABS = { ALL: 'all', UNREAD: 'unread' };
const useNotifications = () => {
const dispatch = useDispatch();
const notifications = useSelector((state) => state.notifications.notifications);
const clearedIds = useSelector((state) => state.notifications.clearedNotificationIds);
const [isOpen, setIsOpen] = useState(false);
const [selectedNotification, setSelectedNotification] = useState(null);
const [activeTab, setActiveTab] = useState(TABS.ALL);
const [pinnedUnreadIds, setPinnedUnreadIds] = useState(null);
const visibleNotifications = useMemo(
() => notifications.filter((n) => !clearedIds?.includes(n.id)),
[notifications, clearedIds]
);
const unreadCount = visibleNotifications.filter((n) => !n.read).length;
// Pin the Unread set on tab entry so reading items doesn't make them vanish.
const listed = useMemo(() => {
if (activeTab !== TABS.UNREAD) return visibleNotifications;
if (!pinnedUnreadIds) return visibleNotifications.filter((n) => !n.read);
return visibleNotifications.filter((n) => pinnedUnreadIds.has(n.id));
}, [activeTab, visibleNotifications, pinnedUnreadIds]);
useEffect(() => {
if (!isOpen) return;
if (selectedNotification && listed.find((n) => n.id === selectedNotification.id)) return;
const first = listed[0];
if (!first) {
setSelectedNotification(null);
return;
}
setSelectedNotification(first);
if (!first.read) {
dispatch(markNotificationAsRead({ notificationId: first.id }));
}
}, [listed, selectedNotification, isOpen]);
const onTabChange = (tab) => {
if (tab === TABS.UNREAD) {
const ids = visibleNotifications.filter((n) => !n.read).map((n) => n.id);
setPinnedUnreadIds(new Set(ids));
} else {
setPinnedUnreadIds(null);
}
setActiveTab(tab);
};
const onSelect = (notification) => {
setSelectedNotification(notification);
if (!notification.read) {
dispatch(markNotificationAsRead({ notificationId: notification.id }));
}
};
const onMarkAllRead = () => {
dispatch(markAllNotificationsAsRead());
if (activeTab === TABS.UNREAD) {
setPinnedUnreadIds(null);
}
};
const onClearAll = () => dispatch(clearAllNotifications());
const open = () => {
window.ipcRenderer?.send('renderer:notifications-opened');
setIsOpen(true);
};
const close = () => {
setIsOpen(false);
setSelectedNotification(null);
setActiveTab(TABS.ALL);
setPinnedUnreadIds(null);
};
return {
isOpen,
visibleNotifications,
listed,
unreadCount,
activeTab,
selectedNotification,
open,
close,
onTabChange,
onSelect,
onMarkAllRead,
onClearAll
};
};
export default useNotifications;

View File

@@ -1,214 +1,24 @@
import { IconBell } from '@tabler/icons';
import { useState } from 'react';
import StyledWrapper from './StyleWrapper';
import Modal from 'components/Modal/index';
import Portal from 'components/Portal';
import { useEffect } from 'react';
import { useApp } from 'providers/App';
import {
fetchNotifications,
markAllNotificationsAsRead,
markNotificationAsRead
} from 'providers/ReduxStore/slices/notifications';
import { useDispatch, useSelector } from 'react-redux';
import { humanizeDate, relativeDate } from 'utils/common';
import ToolHint from 'components/ToolHint';
import DOMPurify from 'dompurify';
const PAGE_SIZE = 5;
import StyledWrapper from './StyledWrapper';
import NotificationsModal from './NotificationsModal';
import useNotifications from './hooks/useNotifications';
const Notifications = () => {
const dispatch = useDispatch();
const { version } = useApp();
const notifications = useSelector((state) => state.notifications.notifications);
const [showNotificationsModal, setShowNotificationsModal] = useState(false);
const [selectedNotification, setSelectedNotification] = useState(null);
const [pageNumber, setPageNumber] = useState(1);
const notificationsStartIndex = (pageNumber - 1) * PAGE_SIZE;
const notificationsEndIndex = pageNumber * PAGE_SIZE;
const totalPages = Math.ceil(notifications.length / PAGE_SIZE);
const unreadNotifications = notifications.filter((notification) => !notification.read);
useEffect(() => {
dispatch(fetchNotifications({
currentVersion: version
}));
}, []);
useEffect(() => {
reset();
}, [showNotificationsModal]);
useEffect(() => {
if (!selectedNotification && notifications?.length > 0 && showNotificationsModal) {
let firstNotification = notifications[0];
setSelectedNotification(firstNotification);
dispatch(markNotificationAsRead({ notificationId: firstNotification?.id }));
}
}, [notifications, selectedNotification, showNotificationsModal]);
const reset = () => {
setSelectedNotification(null);
setPageNumber(1);
};
const handlePrev = (e) => {
if (pageNumber - 1 < 1) return;
setPageNumber(pageNumber - 1);
};
const handleNext = (e) => {
if (pageNumber + 1 > totalPages) return;
setPageNumber(pageNumber + 1);
};
const handleNotificationItemClick = (notification) => (e) => {
e.preventDefault();
setSelectedNotification(notification);
dispatch(markNotificationAsRead({ notificationId: notification?.id }));
};
const getSanitizedDescription = (description) => {
return DOMPurify.sanitize(encodeURIComponent(description), {
ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
});
};
const modalCustomHeader = (
<div className="flex flex-row gap-8">
<div className="bruno-modal-header-title">NOTIFICATIONS</div>
{unreadNotifications.length > 0 && (
<>
<div className="normal-case font-normal">
{unreadNotifications.length} <span>unread notifications</span>
</div>
<button
className={`select-none ${1 == 2 ? 'opacity-50' : 'text-link mark-as-read cursor-pointer hover:underline'}`}
onClick={() => dispatch(markAllNotificationsAsRead())}
>
Mark all as read
</button>
</>
)}
</div>
);
const notifications = useNotifications();
const { isOpen, unreadCount, open, close } = notifications;
return (
<StyledWrapper>
<a
className="relative cursor-pointer"
onClick={() => {
dispatch(fetchNotifications({
currentVersion: version
}));
setShowNotificationsModal(true);
}}
aria-label="Check all Notifications"
>
<>
<StyledWrapper onClick={open} aria-label="Check all Notifications">
<ToolHint text="Notifications" toolhintId="Notifications" offset={8}>
<IconBell
size={16}
aria-hidden
strokeWidth={1.5}
className={`${unreadNotifications?.length > 0 ? 'bell' : ''}`}
/>
{unreadNotifications.length > 0 && (
<span className="notification-count text-xs">{unreadNotifications.length}</span>
)}
<IconBell size={16} aria-hidden strokeWidth={1.5} />
{unreadCount > 0 && <span className="notification-count">{unreadCount}</span>}
</ToolHint>
</a>
</StyledWrapper>
{showNotificationsModal && (
<Portal>
<Modal
size="lg"
title="Notifications"
confirmText="Close"
handleConfirm={() => {
setShowNotificationsModal(false);
}}
handleCancel={() => {
setShowNotificationsModal(false);
}}
hideFooter={true}
customHeader={modalCustomHeader}
disableCloseOnOutsideClick={true}
disableEscapeKey={true}
>
<div className="notifications-modal">
{notifications?.length > 0 ? (
<div className="grid grid-cols-4 flex flex-row">
<div className="col-span-1 flex flex-col">
<ul
className="notifications w-full flex flex-col h-[50vh] max-h-[50vh] overflow-y-auto"
style={{ maxHeight: '50vh', height: '46vh' }}
>
{notifications?.slice(notificationsStartIndex, notificationsEndIndex)?.map((notification) => (
<li
key={notification.id}
className={`p-4 flex flex-col justify-center ${
selectedNotification?.id == notification.id ? 'active' : notification.read ? 'read' : ''
}`}
onClick={handleNotificationItemClick(notification)}
>
<div className="notification-title w-full">{notification?.title}</div>
<div className="notification-date text-xs py-2">{relativeDate(notification?.date)}</div>
</li>
))}
</ul>
<div className="w-full pagination flex flex-row gap-4 justify-center p-2 items-center text-xs">
<button
className={`pl-2 pr-2 py-3 select-none ${
pageNumber <= 1 ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
}`}
onClick={handlePrev}
>
Prev
</button>
<div className="flex flex-row items-center justify-center gap-1">
Page
<div className="w-[20px] flex justify-center" style={{ width: '20px' }}>
{pageNumber}
</div>
of
<div className="w-[20px] flex justify-center" style={{ width: '20px' }}>
{totalPages}
</div>
</div>
<button
className={`pl-2 pr-2 py-3 select-none ${
pageNumber == totalPages ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
}`}
onClick={handleNext}
>
Next
</button>
</div>
</div>
<div className="flex w-full col-span-3 p-4 flex-col">
<div className="w-full text-lg flex flex-wrap h-fit mb-1">{selectedNotification?.title}</div>
<div className="w-full notification-date text-xs mb-4">
{humanizeDate(selectedNotification?.date)}
</div>
<iframe
src={`data:text/html,${getSanitizedDescription(selectedNotification?.description)}`}
sandbox="allow-popups"
style={{ width: '100%', height: '100%' }}
>
</iframe>
</div>
</div>
) : (
<div className="opacity-50 italic text-xs p-12 flex justify-center">You are all caught up!</div>
)}
</div>
</Modal>
</Portal>
)}
</StyledWrapper>
{isOpen && <NotificationsModal notifications={notifications} onClose={close} />}
</>
);
};

View File

@@ -128,17 +128,6 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError,
</div>
))}
</div>
<p className="beta-feedback-inline">
OpenAPI Sync is in Beta we'd love to hear your feedback and suggestions.{' '}
<button
type="button"
className="beta-feedback-link"
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
>
Share feedback
</button>
</p>
</div>
);
};

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconChevronRight,
@@ -15,7 +15,7 @@ import Help from 'components/Help';
import EndpointVisualDiff from './EndpointVisualDiff';
// Expandable row - can be used with or without decision buttons
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => {
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions, preserveValues = true }) => {
const dispatch = useDispatch();
const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`;
const isExpanded = useSelector((state) => {
@@ -25,9 +25,15 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
const [diffData, setDiffData] = useState(null);
const [error, setError] = useState(null);
const loadDiffData = useCallback(async () => {
if (diffData) return;
// Monotonic id so a superseded in-flight fetch (e.g. the user flips the
// Preserve toggle mid-request) can't overwrite the latest result.
const requestIdRef = useRef(0);
const loadDiffData = useCallback(async () => {
// No internal diffData guard: both callers (the expand effect and handleToggle)
// already gate on !diffData. Guarding here would capture a stale diffData from
// the render that recreated this callback and silently skip the toggle re-fetch.
const requestId = ++requestIdRef.current;
setIsLoading(true);
setError(null);
@@ -36,20 +42,45 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', {
collectionPath,
endpointId: endpoint.id,
newSpec
newSpec,
preserveValues
});
if (requestId !== requestIdRef.current) return; // superseded by a newer fetch
if (result.error) {
setError(result.error);
} else {
setDiffData(result);
}
} catch (err) {
if (requestId !== requestIdRef.current) return;
setError(formatIpcError(err) || 'Failed to load diff data');
} finally {
if (requestId === requestIdRef.current) setIsLoading(false);
}
}, [collectionPath, endpoint.id, newSpec, preserveValues]);
// Re-fetch the preview when the preserve toggle changes — the EXPECTED column
// depends on it. Expanded rows re-fetch in place (the old diff stays visible
// and swaps when the new one arrives, so the row never blanks). Collapsed rows
// just drop their cache so the next expand fetches fresh — invisible to the user.
const didMountPreserve = useRef(false);
useEffect(() => {
if (!didMountPreserve.current) {
didMountPreserve.current = true;
return;
}
if (isExpanded) {
loadDiffData(); // bumps requestId, keeps old diff until the new one lands
} else {
requestIdRef.current++; // invalidate any in-flight fetch
setDiffData(null);
setError(null);
setIsLoading(false);
}
}, [collectionPath, endpoint.id, newSpec]);
// Intentionally only re-run when the toggle flips — not on isExpanded/loadDiffData
// changes, which the dedicated load effect + handleToggle already cover.
}, [preserveValues]);
// Load diff data when expanded (e.g. restored from Redux state)
useEffect(() => {
@@ -126,18 +157,21 @@ const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectio
{isExpanded && (
<div className="review-row-diff">
{isLoading && (
{/* Spinner only on the initial load. A re-fetch (e.g. toggling Preserve)
keeps the previous diff visible and swaps it in place, so the row
never blanks/flickers. */}
{isLoading && !diffData && !error && (
<div className="diff-loading">
<IconLoader2 size={16} className="spinning" />
<span>Loading diff...</span>
</div>
)}
{error && (
{error && !diffData && (
<div className="diff-error">
Error: {error}
</div>
)}
{diffData && !isLoading && !error && (
{diffData && !error && (
<EndpointVisualDiff
oldData={diffData.oldData}
newData={diffData.newData}

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};
}
}
}
@@ -1770,6 +1770,7 @@ const StyledWrapper = styled.div`
.bulk-actions {
display: flex;
gap: 0.5rem;
user-select: none; /* these are controls, not selectable text (e.g. double-click on the info icon) */
}
.bulk-btn {
@@ -1804,6 +1805,77 @@ const StyledWrapper = styled.div`
}
}
/* the three Preserve elements read as one control: label + info + toggle */
.preserve-values-control {
display: inline-flex;
align-items: center;
margin-right: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.text};
.preserve-values-label {
white-space: nowrap;
}
/* the shared InfoCircle icon ships a hardcoded ml-2 (8px); override it
so the info icon sits tight to the label. It is a hover-only tooltip
affordance, not a button — use a help cursor and never show a
click/focus box around it. */
svg {
margin-left: 4px;
cursor: help;
}
svg:focus,
svg:focus-visible,
span:focus,
span:focus-visible {
outline: none;
box-shadow: none;
background: transparent;
}
/* compact themed track + knob toggle, sized to the button row height */
.preserve-toggle {
margin-right: 4px; /* space between the toggle and the label */
width: 26px;
height: 14px;
border-radius: 7px;
border: none;
padding: 0;
flex-shrink: 0;
position: relative;
cursor: pointer;
transition: background 0.2s;
background: ${(props) => props.theme.colors.text.muted};
&.active {
background: ${(props) => props.theme.button2.color.primary.bg};
}
&:focus-visible {
outline: 2px solid ${(props) => props.theme.button2.color.primary.bg};
outline-offset: 2px;
}
.preserve-toggle-knob {
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
position: absolute;
top: 2px;
left: 2px;
transition: left 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
&.active .preserve-toggle-knob {
left: 14px;
}
}
}
.sync-review-body {
flex: 1;
overflow-y: auto;

View File

@@ -88,6 +88,7 @@ const SyncReviewPage = ({
}) => {
const dispatch = useDispatch();
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
const [preserveValues, setPreserveValues] = useState(true);
const [showConfirmation, setShowConfirmation] = useState(false);
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
@@ -210,7 +211,8 @@ const SyncReviewPage = ({
newToCollection: filteredAddedEndpoints,
specUpdates: filteredSpecChanges,
resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'),
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming')
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming'),
preserveValues
});
};
@@ -238,6 +240,21 @@ const SyncReviewPage = ({
</div>
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
<div className="bulk-actions">
<div className="preserve-values-control">
<button
type="button"
role="switch"
aria-pressed={preserveValues}
className={`preserve-toggle ${preserveValues ? 'active' : ''}`}
onClick={() => setPreserveValues((v) => !v)}
>
<span className="preserve-toggle-knob" />
</button>
<span className="preserve-values-label">Preserve values</span>
<Help icon="info" size={12} placement="top" width={260}>
When enabled, your edited values are preserved during sync. When disabled, all values are updated to match the OpenAPI spec.
</Help>
</div>
{specDrift?.unifiedDiff && (
<button
className="bulk-btn"
@@ -329,6 +346,7 @@ const SyncReviewPage = ({
showDecisions={true}
decisionLabels={{ keep: 'Keep Current', accept: 'Update' }}
collectionUid={collectionUid}
preserveValues={preserveValues}
/>
)}
/>
@@ -353,6 +371,7 @@ const SyncReviewPage = ({
showDecisions={true}
decisionLabels={{ keep: 'Skip', accept: 'Add' }}
collectionUid={collectionUid}
preserveValues={preserveValues}
/>
)}
/>
@@ -377,6 +396,7 @@ const SyncReviewPage = ({
showDecisions={true}
decisionLabels={{ keep: 'Keep', accept: 'Delete' }}
collectionUid={collectionUid}
preserveValues={preserveValues}
/>
)}
/>

View File

@@ -14,7 +14,7 @@ const useSyncFlow = ({
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync', preserveValues = true) => {
setShowConfirmModal(false);
setIsSyncing(true);
setError(null);
@@ -71,7 +71,8 @@ const useSyncFlow = ({
localOnlyToRemove,
driftedToReset,
mode,
endpointDecisions: decisions
endpointDecisions: decisions,
preserveValues
});
setPendingSyncMode(null);
@@ -102,7 +103,7 @@ const useSyncFlow = ({
const handleApplySync = (selections) => {
const mode = pendingSyncMode || 'sync';
setPendingSyncMode(null);
performSync(selections, mode);
performSync(selections, mode, selections?.preserveValues ?? true);
};
const cancelConfirmModal = () => {

View File

@@ -131,16 +131,6 @@ const OpenAPISyncTab = ({ collection }) => {
error={error}
onOpenSettings={() => setShowSettingsModal(true)}
/>
<p className="beta-feedback-inline">
OpenAPI Sync is in Beta we'd love to hear your feedback and suggestions.{' '}
<button
type="button"
className="beta-feedback-link"
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
>
Share feedback
</button>
</p>
</div>
)}

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

@@ -6,20 +6,38 @@ import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
import toast from 'react-hot-toast';
import { IconFlask } from '@tabler/icons';
import get from 'lodash/get';
import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
// Commented out while there are no active beta features. Re-enable this import when
// adding a beta feature its keys are then referenced as BETA_FEATURE_IDS.MY_FEATURE in the BETA_FEATURES array.
// import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
/**
* UI metadata for beta features rendered in Preferences.
* IDs must match keys from utils/beta-features.js BETA_FEATURES.
* UI metadata for the Beta Features section in Preferences — one entry per toggle.
* The whole tab is data-driven from this array: the form fields, validation schema,
* initial values and the rendered checkboxes are all generated from it.
*
* Each entry has the shape { id, label, description }:
* - id (required) the feature key. MUST be a value from BETA_FEATURES in
* utils/beta-features.js (imported here as BETA_FEATURE_IDS). It is
* used as the preference key (preferences.beta[id]), the form field
* name and the checkbox id, so it must be stable and unique.
* - label (required) short name shown next to the checkbox.
* - description (required) one-line explanation shown under the label.
*
* To add a beta feature:
* 1. Add its key to BETA_FEATURES in utils/beta-features.js (e.g. MY_FEATURE: 'my-feature').
* 2. Add an entry to the array below using BETA_FEATURE_IDS.MY_FEATURE.
* 3. Gate the feature in code with useBetaFeature(BETA_FEATURES.MY_FEATURE).
*
* When the array is empty, the Beta tab shows "No beta features are currently available",
* so a feature can be hidden by simply removing or commenting out its entry.
*/
const BETA_FEATURES = [
{
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
label: 'OpenAPI Sync',
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
}
// {
// id: BETA_FEATURE_IDS.OPENAPI_SYNC,
// label: 'OpenAPI Sync',
// description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
// }
];
const Beta = ({ close }) => {

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';
@@ -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

@@ -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

@@ -295,7 +295,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
{
tokenPlacement === 'header'
? (
<div className="flex items-center gap-4 w-full" key="input-token-prefix">
<div className="flex items-center gap-4 w-full" key="input-token-prefix" data-testid="token-header-prefix">
<label className="block min-w-[140px]">Header Prefix</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
@@ -311,7 +311,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
</div>
)
: (
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key">
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key" data-testid="token-query-param-key">
<label className="block min-w-[140px]">Query Param Key</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor

View File

@@ -185,7 +185,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
{
tokenPlacement === 'header'
? (
<div className="flex items-center gap-4 w-full" key="input-token-prefix">
<div className="flex items-center gap-4 w-full" key="input-token-prefix" data-testid="token-header-prefix">
<label className="block min-w-[140px]">Header Prefix</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
@@ -201,7 +201,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
</div>
)
: (
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key">
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key" data-testid="token-query-param-key">
<label className="block min-w-[140px]">Query Param Key</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor

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

@@ -229,7 +229,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
</div>
{tokenPlacement == 'header' ? (
<div className="flex items-center gap-4 w-full" key="input-token-header-prefix">
<div className="flex items-center gap-4 w-full" key="input-token-header-prefix" data-testid="token-header-prefix">
<label className="block min-w-[140px]">Header Prefix</label>
<div className="oauth2-input-wrapper flex-1">
<SingleLineEditor
@@ -245,7 +245,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
</div>
</div>
) : (
<div className="flex items-center gap-4 w-full" key="input-token-query-key">
<div className="flex items-center gap-4 w-full" key="input-token-query-key" data-testid="token-query-param-key">
<label className="block min-w-[140px]">URL Query Key</label>
<div className="oauth2-input-wrapper flex-1">
<SingleLineEditor

View File

@@ -189,7 +189,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
{
tokenPlacement === 'header'
? (
<div className="flex items-center gap-4 w-full" key="input-token-prefix">
<div className="flex items-center gap-4 w-full" key="input-token-prefix" data-testid="token-header-prefix">
<label className="block min-w-[140px]">Header Prefix</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor
@@ -205,7 +205,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
</div>
)
: (
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key">
<div className="flex items-center gap-4 w-full" key="input-token-query-param-key" data-testid="token-query-param-key">
<label className="block min-w-[140px]">Query Param Key</label>
<div className="single-line-editor-wrapper flex-1">
<SingleLineEditor

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 { getRelativePathWithinBasePath } 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,8 +62,10 @@ 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) => {
return getRelativePathWithinBasePath(collection.pathname, filePath);
});
@@ -66,19 +73,42 @@ const MultipartFormParams = ({ item, collection }) => {
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);
@@ -88,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]);
@@ -115,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 = [
@@ -144,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)}
/>
);
}
@@ -186,6 +202,7 @@ const MultipartFormParams = ({ item, collection }) => {
/>
</div>
<button
data-testid="multipart-file-upload"
className="upload-btn ml-1"
onClick={() => handleBrowseFiles(row, onChange)}
title="Select File"

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';
@@ -10,6 +12,7 @@ import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Script = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -55,6 +58,20 @@ const Script = ({ item, collection }) => {
return () => clearTimeout(timer);
}, [activeTab]);
useFocusErrorLine({
uid: item.uid,
editorRef: preRequestEditorRef,
scriptPhase: 'pre-request',
isVisible: activeTab === 'pre-request'
});
useFocusErrorLine({
uid: item.uid,
editorRef: postResponseEditorRef,
scriptPhase: 'post-response',
isVisible: activeTab === 'post-response'
});
const onRequestScriptEdit = (value) => {
dispatch(
updateRequestScript({
@@ -78,6 +95,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 +123,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,11 +1,14 @@
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';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Tests = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -29,8 +32,16 @@ const Tests = ({ item, collection }) => {
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
useFocusErrorLine({
uid: item.uid,
editorRef: testsEditorRef,
scriptPhase: 'test'
});
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 +58,7 @@ const Tests = ({ item, collection }) => {
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} onApply={onEdit} />
</div>
);
};

View File

@@ -6,6 +6,8 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor';
import InfoTip from 'components/InfoTip';
import DataTypeSelector from 'components/DataTypeSelector';
import { valueToString } from '@usebruno/common/utils';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
@@ -72,17 +74,33 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
</div>
),
placeholder: varType === 'request' ? 'Value' : 'Expr',
render: ({ value, onChange }) => (
<MultiLineEditor
value={value || ''}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
onRun={handleRun}
collection={collection}
item={item}
placeholder={!value ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
render: ({ row, value, onChange, isLastEmptyRow }) => (
<div className="flex items-center w-full gap-2">
<div className="flex-1 min-w-0">
<MultiLineEditor
value={valueToString(value)}
theme={storedTheme}
onSave={onSave}
onChange={onChange}
onRun={handleRun}
collection={collection}
item={item}
placeholder={value == null || (typeof value === 'string' && value.trim() === '') ? (varType === 'request' ? 'Value' : 'Expr') : ''}
/>
</div>
{/* DataTypes apply to literal values, not to the JS expression that produces a post-response value. */}
{!isLastEmptyRow && varType === 'request' && (
<DataTypeSelector
variable={row}
theme={storedTheme}
collection={collection}
onChange={(fields) => {
const updated = (vars || []).map((v) => v.uid === row.uid ? { ...v, ...fields } : v);
handleVarsChange(updated);
}}
/>
)}
</div>
)
}
];
@@ -97,6 +115,7 @@ const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
<StyledWrapper className="w-full">
<EditableTable
tableId="request-vars"
testId={`request-vars-${varType === 'response' ? 'res' : 'req'}`}
columns={columns}
rows={vars || []}
onChange={handleVarsChange}

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,108 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
border-bottom: 1px solid ${(props) => props.theme.border.border0};
&.single {
height: 100%;
/* Dim the row content when disabled, but not the tooltip */
.accordion-left > :not(.toolhint),
.accordion-actions,
.accordion-body {
transition: opacity 0.15s ease;
}
.editor-container {
height: calc(100% - 32px);
&.disabled {
.accordion-left > :not(.toolhint),
.accordion-actions,
.accordion-body {
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-anchor {
display: flex;
min-width: 0;
overflow: hidden;
}
.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,118 @@
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, 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);
const labelTooltipId = `ws-msg-label-${message.uid ?? index}`;
// 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 +122,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 +138,7 @@ export const SingleWSMessage = ({
const onDeleteMessage = () => {
const currentMessages = [...(body.ws || [])];
currentMessages.splice(index, 1);
dispatch(updateRequestBody({
content: currentMessages,
itemUid: item.uid,
@@ -88,97 +146,122 @@ 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>
<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()}
/>
) : (
<ToolHint
text={displayName}
toolhintId={labelTooltipId}
className="message-label-anchor"
place="bottom-start"
positionStrategy="fixed"
tooltipTestId="ws-message-name-tooltip"
tooltipStyle={{ maxWidth: '320px', whiteSpace: 'normal', wordBreak: 'break-word' }}
>
<span
className="message-label"
data-testid={`ws-message-label-${index}`}
onClick={(e) => {
e.preventDefault();
onToggle();
}}
onDoubleClick={handleNameClick}
>
{displayName}
</span>
</ToolHint>
)}
</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

@@ -19,6 +19,7 @@ import VariablesEditor from 'components/VariablesEditor';
import CollectionSettings from 'components/CollectionSettings';
import { DocExplorer } from '@usebruno/graphql-docs';
import FileEditor from 'components/FileEditor';
import StyledWrapper from './StyledWrapper';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
@@ -42,7 +43,9 @@ import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
import OpenAPISyncTab from 'components/OpenAPISyncTab';
import OpenAPISpecTab from 'components/OpenAPISpecTab';
import ChangelogTab from 'components/ChangelogTab';
import CollapsedPanelIndicator from './CollapsedPanelIndicator';
import { clampRequestHeightForResponse } from './paneSize';
import { IconLoader2 } from '@tabler/icons';
const MIN_LEFT_PANE_WIDTH = 300;
@@ -51,6 +54,8 @@ const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const COLLAPSE_EDGE_THRESHOLD = 80;
const EXPAND_EDGE_THRESHOLD = 100;
// Minimum response pane height to show placeholder content on click-expand
const RESPONSE_EXPAND_MIN_HEIGHT = 300;
const RequestTabPanel = () => {
const dispatch = useDispatch();
@@ -262,6 +267,21 @@ const RequestTabPanel = () => {
startDragging(e);
}, [expandResponse, applyPointerResize, startDragging]);
const handleResponseIndicatorClickExpand = useCallback(() => {
expandResponse();
if (!isVerticalLayoutRef.current || !mainSectionRef.current) return;
const { height: containerHeight } = mainSectionRef.current.getBoundingClientRect();
const clampedHeight = clampRequestHeightForResponse(
topPaneHeight,
containerHeight,
RESPONSE_EXPAND_MIN_HEIGHT,
MIN_TOP_PANE_HEIGHT
);
if (clampedHeight != null) {
setTopPaneHeight(clampedHeight);
}
}, [expandResponse, topPaneHeight, setTopPaneHeight]);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
@@ -316,6 +336,10 @@ const RequestTabPanel = () => {
return <Preferences />;
}
if (focusedTab.type === 'changelog') {
return <ChangelogTab />;
}
if (focusedTab.type === 'workspaceOverview') {
return activeWorkspace ? <WorkspaceOverview workspace={activeWorkspace} /> : null;
}
@@ -396,7 +420,7 @@ const RequestTabPanel = () => {
if (folder) {
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<FolderSettings collection={collection} folder={folder} />;
<FolderSettings collection={collection} folder={folder} />
</ScopedPersistenceProvider>
);
}
@@ -458,6 +482,17 @@ const RequestTabPanel = () => {
}));
}
};
if (collection.fileMode) {
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<StyledWrapper className="flex flex-col flex-grow relative p-4 file-mode overflow-hidden">
<FileEditor item={item} collection={collection} />
</StyledWrapper>
</ScopedPersistenceProvider>
);
}
const renderQueryUrl = () => {
if (isGrpcRequest) {
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;
@@ -563,7 +598,7 @@ const RequestTabPanel = () => {
<CollapsedPanelIndicator
panelType="response"
isVertical={isVerticalLayout}
onExpand={expandResponse}
onExpand={handleResponseIndicatorClickExpand}
onDragStart={handleResponseIndicatorDragStart}
dragThresholdPx={isVerticalLayout ? MIN_BOTTOM_PANE_HEIGHT / 2 : MIN_RIGHT_PANE_WIDTH / 2}
/>

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