Compare commits

...

139 Commits

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

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

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

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

* chore: shorten comment in pane expand reducers

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

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

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

* fix: update key column identification to use isKeyField property

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

* fix: update addMultipartFileToLastRow to target the last row correctly

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

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

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

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

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

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

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

* fix

* fix

* fix

* improve: UX

* improve: new message ui

* fix

* fix

* fix

* fix

* fix

* fix: rename message title

* chore: cleanup

* change: add message color

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

---------

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

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

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

* fix/3112

* Implicit grant type

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

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

Refs: BRU-3409

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

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

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

---------

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

* chore: add test

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

---------

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

* REMOVED console.log

* ADDED test case with fixture to test proxy import

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

* RESOLVED githiub converstation

changed afterAll --> afterEach

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

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

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

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

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

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

* refactor: enhance import issue logging and toast notifications

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

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

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

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

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

* style: update ImportIssuesToast styling for improved user experience

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

* addressed review comments

* addressed review comments

* updated the package-lock file

* addressed review comments

* addressed review comments

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

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

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

Three call sites updated:

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

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

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

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

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

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

Add 12 jest tests covering all 3 affected grant types.

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

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

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

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

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

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

---------

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

* Remove console log from snapshot test

Removed console log statement from folder.spec.ts

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

* test(cli): add interpolation multipart  tests

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

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

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

* chore: codestyle

---------

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

* addressed review comments

* addressed review comments

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

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

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

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

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

* feat: add hasGlobalEnvVar method to bru shim

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

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

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

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

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

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

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

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

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

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

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

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

* docs: updated Readme.md

---------

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

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

* addressed review comments

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

* fix: crash ui

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

* test(e2e): environment persistence across collections

* test: migration test

* Update environment.spec.ts

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

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

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

* refactor: update response translation for body assertions

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

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

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

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

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

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

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

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

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

* feat: enhance response assertion translations for negated variants

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

* refactor: convert status assertion utility to ES module syntax

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

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

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

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

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

* fix: reduce padding for dark mode app errors

---------

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

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

* refactor: update response translation for body assertions

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

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

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

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

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

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

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

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

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

* feat: enhance response assertion translations for negated variants

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

* refactor: convert status assertion utility to ES module syntax

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

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

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

* chore: test text

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

* test: non serial

* chore: fix theme

* fix: ui polish

* chore: import

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

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

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

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

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

---------

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

* fix: add selectors for examples with index

* test: better locator

* fix: duplicate name collision

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

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

* addressed review comments

* addressed review comment

* addressed review comment - added a test id

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

* Potential fix for pull request finding

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

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 17:32:27 +05:30
phoval
4ad51186a1 fix(bruno-electron): interpolate auth headers for GraphQL introspection request (#5560) 2026-05-11 11:13:21 +05:30
Chirag Chandrashekhar
0c7bce3320 fix: Update SaveTransientRequestModal empty state when collections don't exist (#7955) 2026-05-08 16:14:07 +05:30
shubh-bruno
327861b353 fix: persist scroll for assertions (#7947) 2026-05-07 23:30:32 +05:30
Sid
8552b47ead feat: request restore (#7948) 2026-05-07 22:17:11 +05:30
shubh-bruno
2c27e016ef fix: persist codeeditor state json/undo/redo (#7946) 2026-05-07 22:05:38 +05:30
Sid
415b75decb feat: snapshot issues with global tabs (#7942)
* chore: fix for sidebar state

* fix: global and sidebar state sync

* fix: re-priroritise how tab uid is synced
2026-05-07 19:03:02 +05:30
Hritam Shrivastava
975c638f39 fix: preserve stream-backed file bodies during request interpolation (#7690) 2026-05-07 14:05:14 +05:30
sanish chirayath
f8bf1460bd refactor: revert HeaderList method names to PropertyList conventions (#7931)
* refactor: update headerList methods and translations for consistency

- Renamed methods in req.headerList and res.headerList from 'forEach' to 'each' for consistency with the new API.
- Updated method translations in the Postman converters to reflect the new method names: 'append' to 'add', 'set' to 'upsert', and 'delete' to 'remove'.
- Adjusted related tests to ensure they validate the new method names and functionality.
- Removed deprecated test cases for 'append' and 'set', replacing them with tests for 'add' and 'upsert'.
- Enhanced documentation to clarify the changes in method names and their usage.

* test: add new tests for HeaderList methods and behavior

- Introduced tests to verify that the 'idx' property is undefined in HeaderList, ensuring compliance with the updated API.
- Added tests to confirm that positional methods (prepend, insert, insertAfter) do not exist in HeaderList, reflecting the recent refactor.
- Implemented a test to check that the two-argument form of the 'add' method correctly overwrites existing headers, enhancing the robustness of header management tests.
2026-05-06 22:55:21 +05:30
sanish chirayath
d39d5ef575 refactor: remove idx from HeaderList, extend ReadOnlyPropertyList, add positional method translations (#7917)
* refactor: remove 'idx' method from headerList and update related tests

- Eliminated the 'idx' method from both req.headerList and res.headerList to streamline header management.
- Updated associated tests and documentation to reflect the removal, ensuring clarity in the API usage and maintaining consistency across the header management system.

* refactor: block unimplemented HeaderList methods with error messages

- Added error handling for unimplemented methods in HeaderList, including idx, add, upsert, remove, each, prepend, insert, and insertAfter.
- Each method now throws a descriptive error indicating the appropriate alternative methods to use, enhancing clarity in the API and guiding users towards correct usage.

* refactor: update error message in idx() method of HeaderList for clarity

- Modified the error message in the idx() method to guide users towards using all()[index] or get(name) instead of the unsupported idx() method, enhancing the clarity of the API documentation.

* refactor: update HeaderList to extend ReadOnlyPropertyList and remove unimplemented methods

- Changed HeaderList to extend ReadOnlyPropertyList instead of PropertyList, streamlining its functionality.
- Removed unimplemented methods (prepend, insert, insertAfter) from HeaderList, clarifying the API and guiding users towards using supported methods.
- Updated related tests to reflect these changes, ensuring consistency and accuracy in header management.

* test: add translations for pm.request.headers methods in request tests

- Introduced new tests to validate the translation of pm.request.headers methods (prepend, insert, insertAfter) to their corresponding req.headerList.append method.
- Enhanced existing tests to ensure accurate conversion and functionality of header management in the Bruno converters.

* feat: enhance header translation methods for pm.request.headers

- Added translations for additional pm.request.headers methods (get, has, one, all, count, indexOf, find, filter, each, map, reduce, toObject, clear) to their corresponding req.headerList methods.
- Updated tests to validate the new translations and ensure accurate header management functionality in the Bruno converters.
2026-05-06 18:54:54 +05:30
Pragadesh-45
50d3862ea3 fix: allow empty header names in CLI and gRPC request preparation (#7925) 2026-05-06 18:52:16 +05:30
naman-bruno
39f8c68124 fix: message in ConnectGitRemote and RemoveGitRemote (#7929) 2026-05-06 18:50:30 +05:30
shubh-bruno
ece742cac8 fix: scrollbar restoration (#7926)
* fix: restore scrollbar

* chore: remove comments

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-05-06 17:43:31 +05:30
Sid
20f4e4263a feat: ui state snapshots (#7794)
* feat(snapshot): add session snapshot persistence and restoration

- Add snapshot middleware to persist UI state (tabs, workspaces, environments)
- Add SnapshotManager service in electron for atomic snapshot storage
- Add accessor-based tab serialization using pathname for reliable restoration
- Add loading states for tabs while collections are mounting
- Add hydrateTabs to restore tabs from snapshots on app load
- Add devTools state persistence (console open/height/tab)

* fix(snapshot): preserve unloaded collections and fix async serialization

Make serializeSnapshot async to fetch existing snapshot before saving,
ensuring collections not currently loaded in Redux are preserved. Fix
activeTab serialization to pass collection object instead of just UID.

* refactor(snapshot): rewrite storage to map-based schema with granular IPC

Replace array-based snapshot storage with key-value maps keyed by pathname
for O(1) lookups. Separate tabs into their own top-level map, decoupled
from collections.

- Rewrite SnapshotManager with map-based schema and granular read/write
  methods (getWorkspace, getTabs, setCollection, removeWorkspace, etc.)
- Add 12 granular IPC handlers (renderer:snapshot:*) replacing 4 coarse ones
- Update middleware serialization to produce maps; remove activeCollectionUidCache
  in favor of lastActiveCollectionPathname on workspace objects
- Fix mountCollection passing collectionUid instead of collection to restoreTabs
- Preserve non-active workspace state from existing snapshot on save

* wip

* refactor: allow migration of old snapshot

* refactor: trim down redundancy

* fix: for workspace state

* feat: fix for finalised schema

* fix: schema cleanup

* chore: simplify

* chore: wait on hydration to finish

* chore: snapshot changes to schema

* chore: fix typo

* fix: switch schema for saving and restoring extras

* chore: correctness changes

* chore: add active in the schema check

* chore: fix lint

* chore: comments

* chore: make writes async

* fix: sorting and cross workspace shared collection fixes

* chore: add version

* fix: wait on hydration

* chore: fix backward compat for ui-snapshots

* chore: dead code removal

* Fix optional chaining in snapshot lookup

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-05-06 17:42:46 +05:30
gopu-bruno
0ed2fc82b4 fix: remove dragbar z-index that was bleeding through modals (#7924) 2026-05-06 15:35:20 +05:30
Sid
973ca18e00 Revert "feat: ws multi message (#7719)" (#7921)
This reverts commit a305b41c93.
2026-05-06 13:59:29 +05:30
Pragadesh-45
e92131ff8a fix(cli): skip headers with empty name when building request (#7869) 2026-05-06 13:57:29 +05:30
Pragadesh-45
5cf807b770 feat: implement proxy-aware API fetching using axios instance (#7767) 2026-05-06 13:49:20 +05:30
gopu-bruno
ba42c22aad fix: response pane overflow on vertical split drag for grpc/ws requests (#7916) 2026-05-05 23:02:56 +05:30
sanish chirayath
eb06a3f197 feat: add PropertyList API for req.headers and res.headers (#7673)
* feat: introduce HeaderList for dynamic header management in BrunoRequest and BrunoResponse

- Implemented HeaderList to provide a dynamic API for managing request headers, allowing real-time reflection of changes.
- Updated BrunoRequest and BrunoResponse to utilize HeaderList for header access, enhancing consistency and usability.
- Added a headers proxy to maintain backward compatibility with existing header access patterns.
- Introduced tests for HeaderList to ensure functionality and reliability across various header operations.

* refactor: update createHeadersProxy to accept a getter function for raw headers

- Modified createHeadersProxy to allow passing a function that retrieves raw headers, enhancing flexibility in header management.
- Updated HeaderList to utilize the new getter function, ensuring consistent behavior when headers are modified.
- Added a test to verify that bracket access reflects changes made via BrunoRequest.setHeaders.

* refactor: enhance headers proxy and syncWriteMethods for improved header management

- Updated createHeadersProxy to clarify precedence of PropertyList methods over header names in bracket access.
- Expanded syncWriteMethods in bruno-request shim to include additional methods for better header manipulation.
- Implemented a custom remove method in property-list-bridge to handle function predicates in-VM, improving the native bridge's functionality.

* refactor: update header management in BrunoRequest and BrunoResponse

- Renamed `req.headers` to `req.headerList` for improved clarity and consistency in header operations.
- Enhanced `HeaderList` to support both dynamic and static modes for header management.
- Updated shims for `req` and `res` to reflect the new header access patterns.
- Modified tests to validate the new header access methods and ensure backward compatibility with raw headers.

* refactor: enhance header translation methods for BrunoRequest and BrunoResponse

- Added comprehensive translations for `req.headerList` and `res.headerList` methods to their respective Postman equivalents, improving consistency in header management.
- Updated tests to validate the new translation methods, ensuring accurate conversion between Bruno and Postman header operations.
- Enhanced existing tests to cover additional header manipulation scenarios, reinforcing the reliability of the header management system.

* refactor: streamline headerList implementation in BrunoRequest and BrunoResponse

- Replaced lazy initialization of `headerList` with direct instantiation in both classes for improved performance and clarity.
- Removed redundant getter methods for `headerList`, simplifying the API.
- Updated tests to reflect changes in headerList instantiation and ensure proper functionality.

* refactor: remove header proxy

* feat: add comprehensive headerList tests for request and response

- Introduced multiple test files for `req.headerList` and `res.headerList` methods, covering various operations such as add, remove, clear, populate, and search methods.
- Implemented tests for both dynamic and read-only scenarios, ensuring robust validation of header management functionalities.
- Enhanced existing headerList methods with detailed assertions to improve reliability and maintainability of the header management system.

* feat: expand STATIC_API_HINTS with additional headerList methods for req and res

- Added a comprehensive list of methods for `req.headerList` and `res.headerList` to enhance autocomplete functionality.
- Included various operations such as get, add, remove, and manipulation methods to improve developer experience and usability.

* feat: implement support for disabled headers in headerList

- Added functionality to track disabled headers in `req.headerList` and `res.headerList`, allowing for better management of header states.
- Updated the `prepareRequest` and `prepareGrpcRequest` functions to include a `disabledHeaders` property.
- Enhanced the `HeaderList` class to handle disabled headers, including methods to filter, count, and retrieve them.
- Introduced tests to validate the behavior of disabled headers, ensuring they are correctly included in the header list while being excluded from raw headers.

* feat: enhance HeaderList with case-insensitive key lookups and improved toObject method

- Implemented case-insensitive key lookups for methods such as get, one, has, and indexOf in HeaderList.
- Updated toObject method to support options for excluding disabled headers, handling duplicate keys, and skipping headers with falsy keys.
- Modified toString method to return headers in HTTP wire format, improving consistency with standard practices.
- Added comprehensive tests to validate new functionalities, ensuring robust header management and accurate behavior for both enabled and disabled headers.

* feat: add context binding to iteration methods in HeaderList

- Enhanced iteration methods (each, filter, find, map, reduce) in HeaderList to accept an optional context parameter, allowing for better control over the `this` binding in callback functions.
- Updated the remove method to support context binding for function predicates.
- Added comprehensive tests to validate the new context binding functionality across various iteration methods, ensuring robust header management.

* feat: enhance HeaderList with new methods for string handling and object support

- Added support for accepting an object with a key property in the `has` method, improving header existence checks.
- Updated `add` and `populate` methods to accept "Key: Value" strings and multi-line header strings, enhancing flexibility in header management.
- Modified `toString` method to ensure a trailing newline in the output, aligning with HTTP wire format standards.
- Introduced comprehensive tests to validate new functionalities, ensuring robust header management and accurate behavior for various input types.

* feat: enhance header management with support for disabled headers and context binding

- Updated `mergeHeaders` function to preserve disabled headers from the request item, improving header state management.
- Modified `addBrunoRequestShimToContext` and `addBrunoResponseShimToContext` to wrap eval code in blocks, preventing redeclaration conflicts.
- Enhanced iteration methods in `property-list-bridge` to support context binding, allowing for better control over `this` in callbacks.
- Added comprehensive tests for `req.headerList` and `res.headerList` to validate new functionalities and ensure robust header management.

* feat: improve header merging to preserve disabled headers from request items

- Enhanced the `mergeHeaders` function to retain disabled headers from the last entry in the request tree path, ensuring better management of header states.
- Updated the logic to include disabled headers while merging, improving the overall functionality of header management.

* feat: update header merging logic to consistently handle disabled headers

- Refactored the `mergeHeaders` function to utilize a separate array for disabled headers, ensuring they are preserved during the merging process.
- Simplified the logic for merging headers by directly combining enabled and disabled headers into the request object, enhancing clarity and maintainability.

* refactor: update disabled headers handling in mergeHeaders function

- Changed the implementation of disabled headers from an array to a Map for better performance and consistency.
- Updated the logic in the `mergeHeaders` function to ensure disabled headers are correctly merged into the request object.
- Enhanced tests to validate the handling of disabled headers, including new scenarios for folder-level and request-level overrides.

* feat: enhance mergeHeaders function to support optional inclusion of disabled headers

- Updated the `mergeHeaders` function to accept an options parameter, allowing for the inclusion of disabled headers in the merged result.
- Modified the logic to handle disabled headers more effectively, ensuring they can be returned based on the provided options.
- Adjusted calls to `mergeHeaders` in various modules to utilize the new functionality, improving header management consistency across the application.

* refactor: remove disabledHeaders from prepareGrpcRequest function

- Eliminated the handling of disabledHeaders in the prepareGrpcRequest function to streamline header management.
- Updated the headers processing logic to focus solely on enabled headers, enhancing clarity and reducing complexity.

* feat: add translations for req.headerList and res.headerList methods

- Introduced new tests to translate methods from req.headerList and res.headerList to their corresponding pm.request.headers and pm.response.headers methods.
- Added translations for methods: one, find, toObject, and upsert, enhancing the functionality of the header management system.

* docs: update HeaderList method aliases and add clarification notes

- Enhanced documentation for `prepend`, `append`, `insert`, and `insertAfter` methods to clarify that they are aliases for `add()` and include a note on the lack of support for ordering and duplicates.
- Updated method comments to reflect the internal handling of headers as a plain object, ensuring better understanding of header management behavior.

* fix: remove duplicate headerList initialization in BrunoRequest class

- Eliminated redundant initialization of headerList in the BrunoRequest constructor, ensuring cleaner code and preventing potential issues with header management.
- This change simplifies the constructor logic while maintaining the intended functionality of the headerList.

* refactor: remove unimplemented headerList methods and update documentation

- Removed the unimplemented methods `prepend`, `append`, `insert`, and `insertAfter` from the headerList, ensuring clarity in the API.
- Updated documentation to reflect that these methods are not implemented and provide guidance to use `add()` or `upsert()` instead.
- Adjusted tests to verify that calls to these methods throw appropriate not-implemented errors, enhancing the robustness of header management.

* refactor: remove unused headerList append method from pre-request script

- Eliminated the call to `req.headerList.append()` in the pre-request script, streamlining the header management process.
- This change aligns with the recent refactor to remove unimplemented methods, ensuring clarity and consistency in the API usage.

* test: update error messages for unimplemented headerList methods

- Changed error messages in tests for `append`, `prepend`, `insert`, and `insertAfter` methods to indicate they are "not yet implemented" instead of "not implemented".
- This update improves clarity in the test outputs, aligning with the current state of the headerList API.

* refactor: remove unimplemented headerList methods and update related tests

- Eliminated the unimplemented methods `prepend`, `append`, `insert`, and `insertAfter` from the HeaderList class and corresponding tests, clarifying the API usage.
- Updated the documentation to guide users towards using `add()` or `upsert()` instead.
- Adjusted tests to remove references to these methods, ensuring they accurately reflect the current state of the headerList API.

* refactor: update HeaderList to accept raw request and response objects

- Modified the HeaderList class to accept the raw request and response objects directly, simplifying the initialization process.
- Updated the BrunoRequest and BrunoResponse classes to reflect this change, ensuring consistent handling of headers.
- Enhanced internal methods to utilize the new structure, improving clarity and maintainability of the header management system.

* refactor: enhance HeaderList header management and update tests

- Updated HeaderList to track removed headers for axios interceptor, improving header casing handling.
- Added new syncWriteMethods to the BrunoResponse shim for better integration.
- Enhanced tests to verify the correct tracking of deleted headers and updated expectations for header management functionality.

* refactor: update header translations and improve HeaderList methods

- Removed unused 'idx' translations from both bruno-to-postman and postman-to-bruno translators to streamline header management.
- Enhanced the HeaderList class to skip existing keys when populating headers, ensuring that current values are preserved.
- Updated tests to reflect the new behavior of the populate method, verifying that existing headers are not overwritten and adjusting expectations accordingly.

* refactor: update headerList.populate behavior and adjust tests

- Modified the req.headerList.populate method to add new headers while preserving existing ones, ensuring that current values are not overwritten.
- Updated the associated test to reflect this new behavior, verifying that existing headers remain intact and new headers are correctly added.

* refactor: update HeaderList methods and translations for consistency

- Renamed `each` method to `forEach` in HeaderList for consistency with standard JavaScript array methods.
- Updated header management methods: replaced `add` with `append`, `upsert` with `set`, and `remove` with `delete` to better reflect their functionality.
- Enhanced translations in bruno-to-postman and postman-to-bruno converters to align with the new method names.
- Adjusted tests to verify the new method names and ensure correct header management behavior.

* refactor: remove unused headerList methods and update related shims

- Removed the `entries`, `keys`, and `values` methods from the HeaderList class to streamline the API and eliminate unused functionality.
- Updated the `bruno-request` and `bruno-response` shims to reflect the removal of these methods, ensuring consistency in header management.
- Adjusted tests to remove references to the deleted methods, maintaining alignment with the current state of the headerList API.

* fix: ensure re-added headers are removed from __headersToDelete

- Updated the HeaderList class to remove headers from the __headersToDelete array when they are re-added, preventing incorrect header deletion behavior.
- Added tests to verify that re-added headers do not remain in __headersToDelete and that their values are correctly set in the raw request headers.
2026-05-05 22:32:43 +05:30
Abhishek S Lal
04732fa3d1 feat(api-spec): drag-to-resize split pane with persisted width (#7866)
* Add drag-resize split pane for API Spec viewer

Introduce a drag-to-resize split pane for the API Spec viewer and persist left pane width. Adds a new useDragResize hook to manage dragging state and clamping, plus UI: dragbar styles, a loading state for the Swagger preview (onComplete + loader), and memoization of the Swagger renderer. Wire up persisted widths via Redux: add updateApiSpecPanelLeftPaneWidth (apiSpec slice) and updateApiSpecTabLeftPaneWidth (tabs slice), and propagate leftPaneWidth / onLeftPaneWidthChange through ApiSpecPanel, OpenAPISpecTab, RequestTabPanel and SpecViewer. Misc: pass tab uid into OpenAPISpecTab and add .gstack/ to .gitignore.

* Refactor SpecViewer and OpenAPISpecTab for improved loading and state management

- Updated SpecViewer to enhance loading state handling for Swagger content, ensuring a smoother user experience by preventing flashes of unrendered content.
- Refactored OpenAPISpecTab to streamline environment context management, optimizing the loading process for OpenAPI specifications.
- Simplified the useDragResize hook by removing unnecessary references and improving the handling of drag events, ensuring better performance and responsiveness during resizing actions.

* Enhance useDragResize hook to clamp width seed and improve test coverage

- Updated the useDragResize hook to clamp the width seed value, ensuring it stays within defined bounds during drag events.
- Added a new test case to verify that an out-of-bounds width seed is correctly clamped and persisted on immediate mouseup, enhancing the robustness of the drag-resize functionality.

* Remove .gstack/ from .gitignore

Delete the .gstack/ ignore entry and normalize the packages/bruno-converters/dist entry in .gitignore (deduplicated). No code changes; just tidy up ignore rules.
2026-05-05 22:28:05 +05:30
Abhishek S Lal
69417adcbf feat(openapi-sync): virtualize spec diff rendering + spec change block navigation (#7848)
* feat: implement side-by-side diff viewer for spec synchronization

- Added a new SpecDiffModal component to display differences between current and updated specs.
- Introduced buildRows function to flatten parsed diff data for rendering.
- Created DiffRow component for rendering individual rows in the diff view.
- Implemented highlightCache for efficient word-level diff highlighting.
- Enhanced user experience with navigation controls for changes and loading indicators.
- Added tests for buildRows functionality to ensure accurate diff representation.

* fix: update comments and dependencies for consistency in SpecDiffModal and StyledWrapper

- Added a comment in StyledWrapper.js to clarify the min-height requirement for Virtuoso's fixedItemHeight.
- Updated comment in highlightCache.js to reflect the change from character-level to word-level diff highlighting.
- Adjusted dependency array in SpecDiffModal.js to include cache for improved performance.
2026-05-05 22:26:46 +05:30
Abhishek S Lal
14b2fe1e65 Centralize OpenAPI sync state in Redux (#7876)
* Centralize OpenAPI sync state in Redux

Move per-collection OpenAPI sync state into the Redux slice and update callers. Adds storedSpec and drift maps and reducers (setDrift, clearDrift, setStoredSpec, clearOpenApiSyncTabState), removes the old diff payload from collectionUpdates, and keeps storedSpecMeta. Components and hooks were updated to use inline selectors (state.openapiSync...) instead of exported selectors, and useOpenAPISync was refactored to persist drift/storedSpec to Redux, use the store to read tabs, and call setDrift/setStoredSpec rather than keeping duplicate local state. The collections closeTabs action now clears openapi-sync state for closing openapi-sync tabs so transient state is dropped when tabs are closed. Small variable renames and minor logic adjustments to use the new shape were included.

* Enhance useOpenAPISync hook with comments for clarity on store usage. This update clarifies that tabs are read-only within handlers to prevent unnecessary re-renders when using useSelector.
2026-05-05 22:16:28 +05:30
Dávid Kaya
15cbdb7d10 fix: enable DPI-aware NSIS installer (#7803)
Declare the NSIS installer as DPI-aware so Windows stops bitmap-scaling the setup UI on high-DPI displays.
2026-05-05 21:41:44 +05:30
James
b91f9ba5be fix: preserve axios default Accept header when setting User-Agent (#7820)
Assigning `defaults.headers.common = { 'User-Agent': ... }` replaced the
entire common headers object, nuking axios's built-in default:

  Accept: application/json, text/plain, */*

This caused servers relying on content-negotiation to receive requests
with no Accept header. Fix by extending the existing object with a
property assignment instead.

Add regression tests for both electron and CLI axios instances verifying
that Accept is preserved and User-Agent is set correctly.

Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
2026-05-05 21:38:12 +05:30
gopu-bruno
ab7dd1ff26 feat: auto-require Postman sandbox globals during import (#7878) 2026-05-05 19:47:00 +05:30
sanish chirayath
d332d8e6b2 feat: add ajv-formats support to jsonSchema assertion (#7897)
* Enhance JSON schema validation by integrating ajv-formats for additional format support in tests and runtime assertions.

* fix: update pre-request script to stop execution instead of running it

* fix: ensure newline at end of file in pre-request script for ping.bru

* refactor: update JSON schema validation tests to assert rejection of invalid formats

* refactor: streamline JSON schema validation by using a default AJV instance and enhance tests for various ajvOptions scenarios

* refactor: update JSON schema tests to use more descriptive property names and improve error handling for invalid formats

* feat: add support for Draft-07 JSON Schema validation and improve error handling for unsupported schema versions

* fix: improve error message for unsupported JSON Schema versions in runtime assertions and tests
2026-05-05 11:57:37 +05:30
shubh-bruno
5ced51d163 feat: persist CodeEditor's json state across tab switching (#7797) 2026-05-04 19:12:24 +05:30
naman-bruno
47a1186c4a feat: integrate Git remote for collections (#7879) 2026-05-04 17:04:47 +05:30
Chirag Chandrashekhar
118ba801aa fix: collection settings access, UI overflow fixes, and auto-focus URL bar (#7861)
* fix: collection settings access, UI overflow fixes, and auto-focus URL bar

- Move collection icon outside dropdown; clicking it opens collection
  settings/overview, clicking the name opens the switcher dropdown
- Auto-focus URL bar when creating a transient request (#2919)
- Fix long collection/folder name overflow with ellipsis truncation
- Reduce dropdown width and truncate large collection names
- Simplify breadcrumb collapse: show collection name and last folder,
  collapse middle items into a dropdown
- Fix modal width to prevent shrinking with short collection names
- Show "Create Collection" option when saving a draft with zero collections
- Use IconBox consistently for collection icons

* Replace Chevron component with IconChevronRight

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-30 17:05:40 +05:30
gopu-bruno
8269d51df4 collapsible request/response split in request tab (#7566) 2026-04-30 11:54:41 +05:30
gopu-bruno
4d6e342fdb fix: prevent assertions from returning wrong values during large iteration runs (#7692) 2026-04-30 11:34:51 +05:30
shubh-bruno
0adf7cd90a feat: persist scroll across tabs (#7695)
* fix: persist scroll

* fix: persist scroll

* chore: style

* fix: remove persisted variabled from localstorage on boot

* fix: persist scroll in request tabs

* fix: persist scroll in folder tabs

* fix: hooks for container and editor scrolls

* fix: persist scroll position in response tabs

* fix: persist scroll for different request bodies

* fix: persist scroll for collection tabs

* fix: test cases

* test: scroll persists tests

* tests: resolved coderabbit comments for tests

* tests: resolved coderabbit comments for tests

* fix: remove only

* fix: test cases

* fix: flaky create collection path as name

* move scrollbar tests

* test cases

* test cases

* test cases

* test cases

* test cases

* fix: moved redundant code to common useTrackScroll function

* chore: spaces

* fix: move usetrackscroll to hook

* chore: cleanup un-needed setTimeout

* fix: linting issues

* chore: example fix

* fix: test cases

* fix: test cases

* fix: flaky scroll tests cases

* chore: revert prop name change

* chore: blank commit

* chore: blank commit

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-29 18:19:24 +05:30
sharan-bruno
13a9f9b8ef Fix 1086: Even after clearing the response, the test count keeps on displaying on the tests tab (#7852) 2026-04-29 11:19:43 +05:30
prateek-bruno
ff6ec4a689 fix: "URL encoding off" ignored for multi-param URLs in generated code (#7769) 2026-04-28 19:13:52 +05:30
prateek-bruno
a688effe67 fix: use stable index in requests tab in report (#7867) 2026-04-28 16:26:53 +05:30
Pooja
a305b41c93 feat: ws multi message (#7719) 2026-04-28 15:31:10 +05:30
ryanjbonnell
7febebace5 Merge pull request #7871 from ryanjbonnell/ryanjbonnell-patch-1
Add missing space in help text of Variables Editor window
2026-04-28 15:07:03 +05:30
prateek-bruno
431ea02e16 feat: new selected list component for importing from git (#7813) 2026-04-28 11:51:58 +05:30
prateek-bruno
a04d434f76 feat: add new parameter "apiKeyHeaderName" for "apikey" mode (#7762) 2026-04-28 11:23:16 +05:30
prateek-bruno
ac2cff90f0 fix: make "Remove Collection" consistent with "Remove Workspace" (#7750) 2026-04-28 11:14:08 +05:30
shubh-bruno
87aefe9849 fix: send-request shortcut (#7853)
* fix: send-request shortcut

* fix: test cases

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: phubadeepjs <ID+phubadeepjs@users.noreply.github.com>
2026-04-27 19:19:54 +05:30
shubh-bruno
9361393a49 fix/search bar in codeeditor (#7841) 2026-04-27 11:25:26 +05:30
ganesh
c91e5fd9c7 fixed noproxy flag (#7586) 2026-04-24 15:24:45 +05:30
ganesh
a7744ee23e update readme file with image (#7721)
* update readme file with image

* updat image with yml extension
2026-04-24 14:56:23 +05:30
Pooja
1f5f726e17 refactor(table): virtualise tables for perf for EditableTable components (#7810) 2026-04-24 13:06:28 +05:30
Steven
9501a14bf8 Fix: Variables Text Missing Whitespace (#7844)
* Added missing space to render text properly
* Switching to the space method used above in file
2026-04-24 00:38:48 +05:30
sanish chirayath
e12b736516 feat: add custom jsonBody Chai assertion + simplify Postman translation (#7299)
* feat: enhance jsonBody translation handling in Postman to Bruno converter

* feat: implement jsonBody assertion for Postman compatibility and enhance translation handling

- Added custom Chai assertion for jsonBody to validate JSON structures, including deep equality and nested properties.
- Updated Postman to Bruno translation logic to utilize the new jsonBody assertion, improving the handling of response validations.
- Enhanced test coverage for jsonBody translations, including positive and negative cases for nested properties and deep equality checks.

* feat: enhance jsonBody assertion translations for Postman compatibility

- Added translations for `pm.response.not.to.have.jsonBody` and `pm.response.to.have.not.jsonBody` to the Postman to Bruno converter.
- Updated tests to cover new translation cases, ensuring proper handling of negation scenarios for JSON body assertions.
- Enhanced existing jsonBody assertion logic to support new translation patterns, improving overall compatibility with Postman syntax.

* feat: add advanced path parsing for jsonBody assertions

- Introduced a new `parsePath` function to handle various property path formats, including dot notation, numeric brackets, and quoted keys.
- Updated the `getNestedValue` function to utilize the new path parsing logic, enhancing the robustness of jsonBody assertions.
- Expanded test cases to cover a wide range of scenarios, including edge cases for bracket notation and keys with special characters.

* docs: add examples for parsePath function in jsonBody assertions

- Enhanced documentation for the `parsePath` function by including examples of various property path formats.
- Updated comments in both `assert-runtime.js` and `test.js` to clarify the handling of dot notation, numeric brackets, and quoted keys.

* fix: improve path handling in assertions for quoted keys

- Updated condition checks in `assert-runtime.js` and `test.js` to ensure proper handling of quoted keys in path parsing.
- Enhanced robustness of the path parsing logic to prevent potential out-of-bounds errors.
2026-04-22 12:59:32 +05:30
sanish chirayath
c4dc0bc10d feat: add JSON Schema validation support with custom chai assertion (#7301)
* feat: add JSON Schema validation support with custom chai assertion

- Introduced a new custom assertion for JSON Schema validation in chai, allowing users to validate response bodies against defined schemas.
- Updated the postman translation logic to translate `pm.response.to.have.jsonSchema` to the new assertion format.
- Enhanced tests to cover various scenarios for JSON Schema validation, ensuring accurate translations and functionality.
- Updated package dependencies to include the latest versions of relevant libraries.

* refactor: enhance JSON Schema validation assertion and add comprehensive test cases

* chore: add @rollup/plugin-json dependency and enhance JSON Schema validation tests

- Added @rollup/plugin-json as a development dependency in package.json and package-lock.json.
- Introduced new test cases for JSON Schema validation, covering various scenarios including valid schema matching, type mismatches, and required field checks.
- Updated existing assertions to utilize the new validation capabilities.

* refactor: streamline JSON Schema validation with default Ajv instance

- Updated the custom chai assertion for JSON Schema validation to utilize a default Ajv instance, improving consistency and reducing redundancy in the code.
- Enhanced the error messages in the assertion to include the actual data being validated, providing clearer feedback during validation failures.

* refactor: improve error messaging in JSON Schema validation assertion

- Enhanced the custom chai assertion for JSON Schema validation to provide clearer error messages by including a stringified version of the data being validated, improving feedback during validation failures.

* refactor: simplify Ajv instance creation in JSON Schema validation

- Removed the default Ajv instance and streamlined the creation of Ajv instances in the custom chai assertion for JSON Schema validation, ensuring consistent handling of ajvOptions across the codebase.

* feat: add support for negated JSON Schema assertions in Postman translations

- Introduced translations for `pm.response.to.not.have.jsonSchema`, `pm.response.not.to.have.jsonSchema`, and `pm.response.to.have.not.jsonSchema` to the new assertion format using `expect`.
- Enhanced the translation logic to handle these new patterns and added corresponding test cases to ensure accurate functionality.
- Updated existing tests to cover various scenarios for negated assertions, improving overall test coverage for JSON Schema validation.

* fix: improve error handling in JSON Schema validation assertions

- Added error handling for JSON schema compilation in the custom chai assertion, ensuring that any compilation errors are caught and reported with a clear message.
- Updated tests to verify that malformed schemas correctly trigger assertion errors, enhancing the robustness of JSON Schema validation.
2026-04-21 17:15:33 +05:30
shubh-bruno
9e92e6f04e fix: shortcut in query builder (#7812)
* fix: enter shortcut for query builder

* chore: remove comments

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-21 16:52:39 +05:30
shubh-bruno
e3e0b688e3 fix: shortcut for query builder (#7805)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-21 11:44:20 +05:30
shubh-bruno
c6281d329a fix: layout glitches on multiline environment variables when scrolling (#7732)
* fix: glitch while scrolling multiline variables in environment

* fix: placeholder issues

* fix: buttons loading at ease

* chore: smoothen the animation

* chore: select between a decent chunk for overscan

* chore: remove magic number

* chore: stick to checking nulls

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-16 16:18:53 +05:30
shubh-bruno
9822ceec6c fix: qol fixes for keybindings (#7709)
* fix: keybindings issues

* chore: let SingleLineEditor handle it's own handleSubmit

* fix: resolve issues

* fix: disable reset default if none are changed

* fix: exlude transient request from reopen last closed tabs

* fix: updated all hardcoded colors to respective theme colors

* chore: pick color from theme

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-15 15:17:42 +05:30
gopu-bruno
b733d0e6f8 fix: generate examples for description only responses in swagger 2.0 converter (#7717) 2026-04-14 18:48:37 +05:30
Sid
ebf60e0c18 fix: avoid round trip loss of annotation data (#7730)
* fix: avoid round trip loss of annotation data

* feat: update types for file , multipart and tests for the same

* chore: optional

* chore: fix body:file annotation

* chore: remove log
2026-04-14 18:47:39 +05:30
shubh-bruno
c5529a9470 fix: response filter in runner (#7747)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-14 15:43:16 +05:30
Pooja
ce3f9a4185 fix: no environment alignment (#7580)
* fix: no environment alignment

* fix
2026-04-14 15:32:30 +05:30
778 changed files with 52920 additions and 5486 deletions

2
.gitattributes vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,4 +1,4 @@
name: Tests
name: Linux Tests
on:
workflow_dispatch:
push:
@@ -8,7 +8,7 @@ on:
jobs:
unit-test:
name: Unit Tests
name: Unit Tests (Linux)
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
@@ -23,7 +23,7 @@ jobs:
uses: ./.github/actions/tests/run-unit-tests
cli-test:
name: CLI Tests
name: CLI Tests (Linux)
runs-on: ubuntu-latest
permissions:
checks: write
@@ -42,13 +42,14 @@ jobs:
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results
check_name: CLI Test Results (Linux)
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
check_run: false
e2e-test:
name: Playwright E2E Tests
timeout-minutes: 60
name: Playwright E2E Tests (Linux)
timeout-minutes: 120
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -58,7 +59,8 @@ jobs:
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
xvfb \
gsettings-desktop-schemas dbus-x11
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
@@ -77,6 +79,61 @@ jobs:
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
name: playwright-report-linux
path: playwright-report/
retention-days: 30
ssl-test:
name: SSL Tests (Linux)
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/ssl/linux/setup-feature-specific-deps
- name: Setup CA Certificates
uses: ./.github/actions/ssl/linux/setup-ca-certs
- name: Run Basic SSL CLI Tests
uses: ./.github/actions/ssl/linux/run-basic-ssl-cli-tests
- name: Run Custom CA Certs CLI Tests
uses: ./.github/actions/ssl/linux/run-custom-ca-certs-cli-tests
- name: Run Custom CA Certs E2E Tests
uses: ./.github/actions/ssl/linux/run-ssl-e2e-tests
oauth1-tests:
name: OAuth 1.0 Auth Tests (Linux)
timeout-minutes: 60
runs-on: ubuntu-latest
permissions:
checks: write
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Setup Feature Dependencies
uses: ./.github/actions/auth/oauth1/linux/setup-feature-specific-deps
- name: Run Auth E2E Tests
uses: ./.github/actions/auth/oauth1/linux/run-auth-e2e-tests
- name: Start Test Server
uses: ./.github/actions/auth/oauth1/linux/start-test-server
- name: Run OAuth1 CLI Tests
uses: ./.github/actions/auth/oauth1/linux/run-oauth1-cli-tests

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

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

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

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

6
.gitignore vendored
View File

@@ -58,6 +58,10 @@ skills-lock.json
# Playwright
/blob-report/
# Benchmark results (generated at runtime)
tests/benchmarks/results/
/benchmark-report/
# Development plan files
CLAUDE.md
AGENTS.md
@@ -67,4 +71,4 @@ AGENTS.md
packages/bruno-filestore/dist
packages/bruno-requests/dist
packages/bruno-schema-types/dist
packages/bruno-converters/dist
packages/bruno-converters/dist

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 615 KiB

825
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,75 @@
import { memo } from 'react';
import SwaggerUI from 'swagger-ui-react';
import StyledWrapper from './StyledWrapper';
import { serializeBody } from './serializeBody';
const Swagger = ({ spec }) => {
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} />
<SwaggerUI
spec={spec}
onComplete={onComplete}
requestInterceptor={requestInterceptor}
/>
</div>
</StyledWrapper>
);
};
export default Swagger;
export default memo(Swagger);

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

@@ -1,26 +1,31 @@
import React, { useState, useEffect, Suspense } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { IconDeviceFloppy } from '@tabler/icons';
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
import CodeEditor from './FileEditor/CodeEditor/index';
import Swagger from './Renderers/Swagger';
import { useDragResize } from 'hooks/useDragResize';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 450;
/**
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
*
* Props:
* - content (string) The spec content (YAML/JSON string)
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
* - onSave (function) Called with current editor content on save (editable mode only)
* - content (string) The spec content (YAML/JSON string)
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
* - onSave (fn) Called with current editor content on save (editable mode only)
* - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default
* - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp)
*/
const SpecViewer = ({ content, readOnly, onSave }) => {
const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [editorContent, setEditorContent] = useState(content);
// Sync editor when saved content changes from outside (e.g. after save completes)
useEffect(() => {
setEditorContent(content);
}, [content]);
@@ -31,38 +36,85 @@ const SpecViewer = ({ content, readOnly, onSave }) => {
if (onSave) onSave(editorContent);
};
const mainSectionRef = useRef(null);
const { dragging, dragWidth, dragbarProps } = useDragResize({
containerRef: mainSectionRef,
width: leftPaneWidth,
onWidthChange: onLeftPaneWidthChange,
minLeft: MIN_LEFT_PANE_WIDTH,
minRight: MIN_RIGHT_PANE_WIDTH
});
const effectiveWidth = dragging ? dragWidth : leftPaneWidth;
const leftPaneStyle = effectiveWidth != null
? { width: `${effectiveWidth}px`, flexShrink: 0 }
: { flex: '1 1 50%', minWidth: 0 };
const [swaggerReady, setSwaggerReady] = useState(false);
useEffect(() => {
setSwaggerReady(false);
}, [content]);
const handleSwaggerComplete = useCallback(() => {
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
// before hiding the loader — avoids a flash of unrendered content.
requestAnimationFrame(() => {
requestAnimationFrame(() => setSwaggerReady(true));
});
}, []);
return (
<section className="main flex flex-grow pl-4 relative">
<div className="w-full grid grid-cols-2">
<div className="col-span-1">
<div className="flex flex-grow relative">
<CodeEditor
theme={displayedTheme}
value={readOnly ? content : editorContent}
readOnly={readOnly ? 'nocursor' : false}
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
onSave={readOnly ? undefined : handleSave}
mode="yaml"
font={get(preferences, 'font.codeFont', 'default')}
/>
{!readOnly && onSave && (
<IconDeviceFloppy
onClick={handleSave}
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'
}`}
/>
)}
<section
ref={mainSectionRef}
className={`main flex flex-grow pl-4 relative ${dragging ? 'dragging' : ''}`}
>
<div
className="api-spec-left-pane flex flex-grow relative h-full"
style={leftPaneStyle}
>
<CodeEditor
theme={displayedTheme}
value={readOnly ? content : editorContent}
readOnly={readOnly ? 'nocursor' : false}
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
onSave={readOnly ? undefined : handleSave}
mode="yaml"
font={get(preferences, 'font.codeFont', 'default')}
/>
{!readOnly && onSave && (
<IconDeviceFloppy
onClick={handleSave}
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>
<div className="dragbar-wrapper" {...dragbarProps}>
<div className="dragbar-handle" />
</div>
<div
className="api-spec-right-pane relative"
style={{ flex: '1 1 50%', minWidth: 0 }}
>
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
<Swagger spec={content} onComplete={handleSwaggerComplete} />
</div>
{!swaggerReady && (
<div
className="absolute inset-0 flex items-center justify-center gap-2"
style={{ background: theme.bg }}
>
<div className="flex items-center justify-center gap-2 opacity-70">
<IconLoader2 size={20} className="animate-spin" />
<span>Generating preview</span>
</div>
</div>
</div>
<div className="col-span-1">
<Suspense fallback="">
<Swagger spec={content} />
</Suspense>
</div>
)}
</div>
</section>
);

View File

@@ -17,6 +17,35 @@ const StyledWrapper = styled.div`
.react-tooltip {
z-index: 10;
}
section.main.dragging {
cursor: col-resize;
user-select: none;
}
div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
padding: 0;
cursor: col-resize;
background: transparent;
position: relative;
flex-shrink: 0;
div.dragbar-handle {
display: flex;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
`;
export default StyledWrapper;

View File

@@ -1,11 +1,11 @@
import React, { forwardRef, useRef } from 'react';
import React, { forwardRef, useRef, useCallback } from 'react';
import find from 'lodash/find';
import { useSelector, useDispatch } from 'react-redux';
import { IconFileCode, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import SpecViewer from './SpecViewer';
import Dropdown from 'components/Dropdown';
import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import toast from 'react-hot-toast';
@@ -21,7 +21,16 @@ const ApiSpecPanel = () => {
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
const { filename, pathname, raw, uid } = apiSpec || {};
const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
const handleLeftPaneWidthChange = useCallback(
(w) => {
if (!uid) return;
dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
},
[dispatch, uid]
);
if (!uid) {
return <div className="p-4 opacity-50">API Spec not found!</div>;
}
@@ -79,6 +88,8 @@ const ApiSpecPanel = () => {
<SpecViewer
content={raw}
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
leftPaneWidth={leftPaneWidth ?? null}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
</StyledWrapper>
);

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
/>
</div>
<div className="flex btn-action justify-between items-center mt-3">
<button className="text-link select-none ml-auto" onClick={onToggle}>
<button className="text-link select-none ml-auto" data-testid="key-value-edit-toggle" onClick={onToggle}>
Key/Value Edit
</button>
</div>

View File

@@ -6,7 +6,7 @@
*/
import React, { createRef } from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { debounce, isEqual } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
@@ -16,7 +16,16 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import { setupCodeMirrorResizeRefresh } from 'utils/codemirror/resize';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
import {
applyEditorState,
captureEditorState,
getDocKey,
readPersistedEditorState,
writePersistedEditorState
} from './state-persistence';
import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -24,7 +33,7 @@ window.JSHINT = JSHINT;
const TAB_SIZE = 2;
export default class CodeEditor extends React.Component {
class CodeEditor extends React.Component {
constructor(props) {
super(props);
@@ -48,8 +57,24 @@ export default class CodeEditor extends React.Component {
};
}
// Thin wrapper around the pure getDocKey helper from state-persistence.js.
// Kept on the class so the rest of the lifecycle code reads naturally.
_getDocKey() {
return getDocKey(this.props);
}
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
@@ -84,8 +109,10 @@ export default class CodeEditor extends React.Component {
this.searchBarRef.current?.focus();
});
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
'Cmd-H': this.props.readOnly ? false : 'replace',
'Ctrl-H': this.props.readOnly ? false : 'replace',
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut,
'Tab': function (cm) {
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
? cm.execCommand('indentMore')
@@ -175,9 +202,49 @@ export default class CodeEditor extends React.Component {
});
if (editor) {
// CM5 was constructed with props.value, so the editor already shows the
// right content. Read this tab's previously persisted view state from
// localStorage and apply it on top — restores folds, cursor, selection,
// undo history, and scroll position.
const docKey = getDocKey(this.props);
this._currentDocKey = docKey;
this.cachedValue = editor.getValue();
applyEditorState(
editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }),
this.cachedValue
);
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
// Persist view state immediately when the user folds or unfolds — without
// this, a fold only gets saved on the next tab switch / unmount. That
// makes the persistence feel "delayed" or random, especially across
// sub-tab switches that don't change the docKey or unmount the editor.
// Debounced so rapid fold/unfold (e.g. Cmd-Y to fold all) doesn't write
// to localStorage on every event.
this._persistViewStateDebounced = debounce(() => {
if (!this.editor || !this._currentDocKey) return;
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}, 250);
editor.on('fold', this._persistViewStateDebounced);
editor.on('unfold', this._persistViewStateDebounced);
editor.scrollTo(null, this.props.initialScroll);
this._lastScrollTop = this.props.initialScroll || 0;
editor.on('scroll', () => {
const wrapper = editor.getWrapperElement();
if (wrapper && wrapper.offsetParent === null) return;
this._lastScrollTop = editor.getScrollInfo().top;
if (this.props.onScroll && typeof this.props.onScroll === 'function') {
this.props.onScroll(this._lastScrollTop);
}
});
this.addOverlay();
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
@@ -203,6 +270,8 @@ export default class CodeEditor extends React.Component {
if (cmInput) {
cmInput.classList.add('mousetrap');
}
this.cleanupResizeRefresh = setupCodeMirrorResizeRefresh(editor, this._node);
}
}
@@ -218,11 +287,52 @@ export default class CodeEditor extends React.Component {
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 = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
if (this.editor) {
// Two distinct update paths:
// 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state
// 2. Same doc, value changed → external content update → setValue (view state resets)
const newDocKey = getDocKey(this.props);
const docKeyChanged = newDocKey !== this._currentDocKey;
if (docKeyChanged) {
// Path 1 — tab switch.
// Snapshot the outgoing tab's view state to localStorage so a future
// visit can restore it. Then setValue the incoming content and apply
// any view state previously persisted for the incoming tab.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this._currentDocKey = newDocKey;
applyEditorState(
this.editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }),
this.cachedValue
);
// setValue resets the editor's mode-overlay state — re-apply the
// brunovariables overlay and re-evaluate lint config for the new content.
this.addOverlay();
this.editor.setOption(
'lint',
this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false
);
} else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) {
// Path 2 — same tab, new external value (e.g. a fresh response arrived
// while this tab was active). Update content; view state resets because
// line positions no longer correspond to anything. Invalidate the
// persisted snapshot too, since the saved cursor/folds/history reflect
// the prior content.
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null });
}
}
if (this.editor) {
@@ -268,14 +378,34 @@ export default class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this.editor);
this.props.onScroll(this._lastScrollTop);
}
// Snapshot view state to localStorage before tearing down the editor so
// the next mount of a CodeEditor with this docKey can restore folds,
// cursor, selection, undo history, and scroll position.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
// Tear down the debounced fold-persistence listener. Cancel any pending
// call so it can't fire after we've already snapshotted state above.
if (this._persistViewStateDebounced) {
this.editor.off('fold', this._persistViewStateDebounced);
this.editor.off('unfold', this._persistViewStateDebounced);
this._persistViewStateDebounced.cancel?.();
}
// Clean up lint error tooltip
this.cleanupLintErrorTooltip?.();
this.cleanupResizeRefresh?.();
const wrapper = this.editor.getWrapperElement();
wrapper?.parentNode?.removeChild(wrapper);
@@ -337,3 +467,12 @@ export default class CodeEditor extends React.Component {
}
};
}
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
const persistenceScope = usePersistenceScope();
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
});
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
export default CodeEditorWithPersistenceScope;

View File

@@ -0,0 +1,129 @@
/*
* CodeEditor view-state persistence — extracted for testability.
*
* Why this exists:
* Every tab switch causes CodeMirror's setValue() to wipe folds, cursor,
* selection, undo history, and scroll position. To preserve them, we serialize
* the relevant pieces to localStorage under a stable key for each editor and
* re-apply them on mount / tab switch. CodeMirror exposes a JSON-serializable
* representation of its undo stack via getHistory()/setHistory(), which is what
* makes Cmd-Z continue working across switches.
*
* Note: we deliberately do NOT persist the content itself — the canonical value
* lives in Redux (props.value). We only persist the editor's "view" state on
* top of that content. If content has drifted between save and restore, fold
* positions are applied leniently (foldCode silently no-ops on invalid lines)
* and history is skipped to avoid an inconsistent undo stack.
*/
export const STORAGE_PREFIX = 'persisted::';
export const DEFAULT_PERSISTENCE_SCOPE = 'global';
export const STORAGE_SEGMENT = 'codeeditor';
export const getScopedStorageKey = (scope, key) => {
const resolvedScope = scope || DEFAULT_PERSISTENCE_SCOPE;
return `${STORAGE_PREFIX}${resolvedScope}::${STORAGE_SEGMENT}::${key}`;
};
// Identifies which Doc state belongs to a given CodeEditor instance.
//
// Callers can pass an explicit `docKey` prop when the auto-derived key would
// collide — e.g. Pre-Request vs Post-Response script editors share the same
// item/mode/readOnly and need an extra disambiguator.
//
// Auto-derived parts:
// id — distinguishes different tabs (requests or collections)
// mode — distinguishes editors within the same tab (e.g. JSON body vs JS script)
// readOnly — distinguishes response viewer (ro) from body editor (rw) when modes match
export const getDocKey = (props) => {
if (props.docKey) return props.docKey;
const id = props.item?.uid || props.collection?.uid || 'default';
const mode = props.mode || 'default';
const readOnly = props.readOnly ? 'ro' : 'rw';
return `${id}:${mode}:${readOnly}`;
};
export const readPersistedEditorState = ({ scope, key }) => {
try {
const raw = localStorage.getItem(getScopedStorageKey(scope, key));
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
};
export const writePersistedEditorState = ({ scope, key, state }) => {
try {
const storageKey = getScopedStorageKey(scope, key);
if (state == null) {
localStorage.removeItem(storageKey);
} else {
localStorage.setItem(storageKey, JSON.stringify(state));
}
} catch {
// localStorage may be unavailable or full (Chromium ~10 MB cap). Editor
// state is non-critical — content lives in Redux — so silently ignore.
}
};
export const captureEditorState = (editor) => {
if (!editor) return null;
const doc = editor.getDoc();
const folds = editor
.getAllMarks()
.filter((m) => m.__isFold)
.map((m) => m.find())
.filter(Boolean)
.map((range) => range.from);
return {
contentLength: doc.getValue().length,
cursor: doc.getCursor(),
selections: doc.listSelections(),
history: doc.getHistory(),
folds,
scrollY: editor.getScrollInfo().top
};
};
export const applyEditorState = (editor, state, currentContent) => {
if (!editor || !state) return;
const doc = editor.getDoc();
const contentMatches = state.contentLength === (currentContent || '').length;
// History/cursor/selection only make sense if content didn't drift — applying
// a stale undo stack to different content would let Cmd-Z replay edits that
// no longer correspond to anything visible.
if (contentMatches) {
if (state.history) {
try { doc.setHistory(state.history); } catch {}
}
if (state.cursor) {
try { doc.setCursor(state.cursor); } catch {}
}
if (state.selections && state.selections.length) {
try { doc.setSelections(state.selections); } catch {}
}
}
// Folds are cheap and lenient — try them either way.
// Sort innermost-first (line desc): when folds are nested, applying the
// inner one before the outer one is safer because brace-fold's findRange
// re-scans the line text. With outer-first, deeply nested arrays inside a
// folded object can fail to refold (issue specific to JSON arrays where
// the helper's lookback can land on the wrong opening character once the
// outer block is collapsed).
if (state.folds && state.folds.length) {
const sorted = [...state.folds].sort(
(a, b) => b.line - a.line || b.ch - a.ch
);
editor.operation(() => {
sorted.forEach((from) => {
try {
editor.foldCode(from, null, 'fold');
} catch {}
});
});
}
if (state.scrollY != null) {
try { editor.scrollTo(null, state.scrollY); } catch {}
}
};

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

@@ -1,8 +1,10 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
@@ -11,16 +13,27 @@ import StyledWrapper from './StyledWrapper';
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
import Button from 'ui/Button/index';
import ActionIcon from 'ui/ActionIcon/index';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Docs = ({ collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const [isEditing, setIsEditing] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const isEditing = focusedTab?.docsEditing || false;
const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
const preferences = useSelector((state) => state.app.preferences);
// StyledWrapper has overflow-y: auto — use null selector.
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-docs-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
setIsEditing((prev) => !prev);
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
};
const onEdit = (value) => {
@@ -48,7 +61,7 @@ const Docs = ({ collection }) => {
};
return (
<StyledWrapper className="h-full w-full relative flex flex-col">
<StyledWrapper className="h-full w-full relative flex flex-col" ref={wrapperRef}>
<div className="flex flex-row w-full justify-between items-center mb-4">
<div className="text-lg font-medium flex items-center gap-2">
<IconFileText size={20} strokeWidth={1.5} />
@@ -81,9 +94,11 @@ const Docs = ({ collection }) => {
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<div className="h-full overflow-auto pl-1">
<div className="pl-1">
<div className="h-[1px] min-h-[500px]">
{
docs?.length > 0

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -13,6 +13,8 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -25,6 +27,9 @@ const Headers = ({ collection }) => {
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-headers-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
@@ -120,7 +125,7 @@ const Headers = ({ collection }) => {
}
return (
<StyledWrapper className="h-full w-full">
<StyledWrapper className="h-full w-full" ref={wrapperRef}>
<div className="text-xs mb-4 text-muted">
Add request headers that will be sent with every request in this collection.
</div>
@@ -133,9 +138,10 @@ const Headers = ({ collection }) => {
getRowError={getRowError}
columnWidths={collectionHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

@@ -8,10 +8,12 @@ const Overview = ({ collection }) => {
return (
<div className="h-full">
<div className="grid grid-cols-5 gap-5 h-full">
<div className="col-span-2">
<div className="text-lg font-medium flex items-center gap-2">
<IconBox size={20} stroke={1.5} />
{collection?.name}
<div className="col-span-2 overflow-clip text-ellipsis">
<div className="flex gap-2 items-center min-w-0">
<IconBox size={20} stroke={1.5} className="flex-shrink-0" />
<span className="overflow-hidden text-lg font-medium whitespace-nowrap text-ellipsis">
{collection?.name}
</span>
</div>
<Info collection={collection} />
<RequestsNotLoaded collection={collection} />

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';
@@ -12,6 +13,7 @@ import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ collection }) => {
const dispatch = useDispatch();
@@ -38,13 +40,20 @@ const Script = ({ collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Refresh CodeMirror when tab becomes visible
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `collection-pre-req-scroll-${collection.uid}`, default: 0 });
const [postResScroll, setPostResScroll] = usePersistedState({ key: `collection-post-res-scroll-${collection.uid}`, default: 0 });
// Refresh CodeMirror when tab becomes visible and restore scroll position.
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
}
}, 0);
@@ -99,34 +108,54 @@ const Script = ({ collection }) => {
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
<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">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
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']}
/>
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
<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

@@ -1,19 +1,23 @@
import React from 'react';
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';
const Tests = ({ collection }) => {
const dispatch = useDispatch();
const testsEditorRef = useRef(null);
const tests = collection.draft?.root ? get(collection, 'draft.root.request.tests', '') : get(collection, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [testsScroll, setTestsScroll] = usePersistedState({ key: `collection-tests-scroll-${collection.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -29,17 +33,24 @@ const Tests = ({ collection }) => {
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
<CodeEditor
collection={collection}
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']}
/>
<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

@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ collection, vars, varType }) => {
const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -87,6 +87,7 @@ const VarsTable = ({ collection, vars, varType }) => {
getRowError={getRowError}
columnWidths={collectionVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

@@ -1,10 +1,12 @@
import React from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Vars = ({ collection }) => {
const dispatch = useDispatch();
@@ -12,15 +14,19 @@ const Vars = ({ collection }) => {
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-vars-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="w-full flex flex-col">
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<div className="flex-1">
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable collection={collection} vars={requestVars} varType="request" />
<VarsTable collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
</div>
<div className="flex-1">
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable collection={collection} vars={responseVars} varType="response" />
<VarsTable collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -15,6 +15,7 @@ import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import StatusDot from 'components/StatusDot';
import Overview from './Overview/index';
import { DEFAULT_PRESET_REQUEST_TYPE } from 'utils/common/constants';
const CollectionSettings = ({ collection }) => {
const dispatch = useDispatch();
@@ -60,7 +61,7 @@ const CollectionSettings = ({ collection }) => {
? get(collection, 'draft.brunoConfig.protobuf', {})
: get(collection, 'brunoConfig.protobuf', {});
const presets = collection.draft?.brunoConfig ? get(collection, 'draft.brunoConfig.presets', {}) : get(collection, 'brunoConfig.presets', {});
const hasPresets = presets && presets.requestUrl !== '';
const hasPresets = presets && ((presets.requestType && presets.requestType !== DEFAULT_PRESET_REQUEST_TYPE) || (presets.requestUrl && presets.requestUrl !== ''));
const getTabPanel = (tab) => {
switch (tab) {
@@ -146,7 +147,7 @@ const CollectionSettings = ({ collection }) => {
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>
</div>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="collection-settings-content mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>
);
};

View File

@@ -4,12 +4,14 @@ import find from 'lodash/find';
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Documentation = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -21,6 +23,10 @@ const Documentation = ({ item, collection }) => {
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
const preferences = useSelector((state) => state.app.preferences);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-docs-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
};
@@ -42,7 +48,7 @@ const Documentation = ({ item, collection }) => {
}
return (
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative" ref={wrapperRef}>
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
@@ -57,6 +63,8 @@ const Documentation = ({ item, collection }) => {
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />

View File

@@ -179,6 +179,17 @@ const Wrapper = styled.div`
}
}
.breadcrumb-collapsed-dropdown {
max-width: 250px;
}
.breadcrumb-collapsed-item {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-separator {
height: 1px;
background-color: ${(props) => props.theme.dropdown.separator};

View File

@@ -1,10 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
display: block;
width: 100%;
isolation: isolate;
&.is-resizing {
cursor: col-resize !important;
@@ -12,9 +11,9 @@ const StyledWrapper = styled.div`
}
.table-container {
overflow: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
overflow: clip;
}
table {
@@ -80,6 +79,8 @@ const StyledWrapper = styled.div`
tbody {
tr {
height: 35px;
max-height: 35px;
transition: background 0.1s ease;
&:last-child td {
@@ -87,6 +88,8 @@ const StyledWrapper = styled.div`
}
td {
height: 35px;
max-height: 35px;
padding: 1px 10px !important;
border-top: none !important;
border-left: none !important;
@@ -96,17 +99,23 @@ const StyledWrapper = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
&:last-child {
border-right: none;
> div:not(.drag-handle) {
height: 33px;
max-height: 33px;
overflow: hidden;
}
/* Handle CodeMirror editors overflow */
.cm-editor {
max-width: 100%;
height: 33px !important;
max-height: 33px !important;
.cm-scroller {
overflow: hidden !important;
max-height: 33px;
}
.cm-content {
@@ -185,12 +194,23 @@ const StyledWrapper = styled.div`
}
.drag-handle {
opacity: 0;
transition: opacity 0.1s ease;
display: flex;
align-items: center;
justify-content: center;
.icon-grip,
.icon-minus {
color: ${(props) => props.theme.colors.text.muted};
}
}
tbody tr:hover .drag-handle,
tbody tr.drag-over .drag-handle {
opacity: 1;
}
select {
background-color: transparent;
color: ${(props) => props.theme.text};

View File

@@ -1,10 +1,49 @@
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const ROW_HEIGHT = 35;
const findScrollParent = (element) => {
let parent = element?.parentElement;
while (parent) {
const { overflowY } = getComputedStyle(parent);
if (overflowY === 'auto' || overflowY === 'scroll') return parent;
parent = parent.parentElement;
}
return null;
};
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 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;
return (
<tr
{...rest}
className={className}
draggable={canDrag}
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
onDragLeave={canDrag ? (e) => onDragLeave(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => onDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? onDragEnd : undefined}
>
{children}
</tr>
);
}
);
const EditableTable = ({
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
@@ -23,15 +62,27 @@ const EditableTable = ({
showAddRow = true,
testId = 'editable-table',
columnWidths,
initialScroll = 0,
onColumnWidthsChange
}) => {
const tableRef = useRef(null);
const wrapperRef = useRef(null);
const virtuosoRef = useRef(null);
const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null);
const prevRowCountRef = useRef(0);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const [scrollParent, setScrollParent] = useState(null);
const [dragOverRow, setDragOverRow] = useState(null);
const widths = columnWidths || {};
useLayoutEffect(() => {
setScrollParent(findScrollParent(wrapperRef.current));
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
const handleColumnWidthsChange = useCallback((newWidths) => {
onColumnWidthsChange?.(newWidths);
}, [onColumnWidthsChange]);
@@ -71,7 +122,7 @@ const EditableTable = ({
const handleMouseUp = () => {
// Convert pixel widths to percentages for responsive scaling
const table = tableRef.current?.querySelector('table');
const table = wrapperRef.current?.querySelector('table');
if (table) {
const tableWidth = table.offsetWidth;
const headerCells = table.querySelectorAll('thead td');
@@ -103,23 +154,6 @@ const EditableTable = ({
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
// Track table height for resize handles
useEffect(() => {
const table = tableRef.current?.querySelector('table');
if (!table) return;
const updateHeight = () => {
setTableHeight(table.offsetHeight);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(table);
return () => resizeObserver.disconnect();
}, [rows.length]);
const getColumnWidth = useCallback((column) => {
return widths[column.key] || column.width || 'auto';
}, [widths]);
@@ -134,6 +168,17 @@ const EditableTable = ({
};
}, [defaultRow, checkboxKey]);
const hasAnyValue = useCallback((row) => {
for (const col of columns) {
const val = col.getValue ? col.getValue(row) : row[col.key];
const defaultVal = defaultRow[col.key];
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
return true;
}
}
return false;
}, [columns, defaultRow]);
const rowsWithEmpty = useMemo(() => {
if (!showAddRow) {
return rows;
@@ -143,16 +188,11 @@ const EditableTable = ({
return [createEmptyRow()];
}
const lastRow = rows[rows.length - 1];
const keyColumn = columns.find((col) => col.isKeyField);
if (keyColumn) {
const lastRowKeyValue = keyColumn.getValue ? keyColumn.getValue(lastRow) : lastRow[keyColumn.key];
const isLastRowEmpty = !lastRowKeyValue || (typeof lastRowKeyValue === 'string' && lastRowKeyValue.trim() === '');
if (isLastRowEmpty) {
return rows;
}
// If the last row is already empty (e.g. a stray empty row loaded from a
// pre-existing file), don't append another one — otherwise the table would
// render two empty rows at the bottom on the initial render.
if (!hasAnyValue(rows[rows.length - 1])) {
return rows;
}
if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) {
@@ -164,69 +204,45 @@ 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;
return index === rowsWithEmpty.length - 1 && isEmptyRow(row);
}, [rowsWithEmpty.length, isEmptyRow, showAddRow]);
useEffect(() => {
if (rowsWithEmpty.length > prevRowCountRef.current && prevRowCountRef.current > 0) {
virtuosoRef.current?.scrollToIndex({
index: rowsWithEmpty.length - 1,
behavior: 'smooth'
});
}
prevRowCountRef.current = rowsWithEmpty.length;
}, [rowsWithEmpty.length]);
const handleValueChange = useCallback((rowUid, key, value) => {
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);
@@ -245,28 +261,31 @@ const EditableTable = ({
const handleDragOver = useCallback((e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setHoveredRow(index);
setDragOverRow((prev) => (prev === index ? prev : index));
}, []);
const handleDragLeave = useCallback((e, index) => {
if (e.currentTarget.contains(e.relatedTarget)) return;
setDragOverRow((prev) => (prev === index ? null : prev));
}, []);
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
const handleDrop = useCallback((e, toIndex) => {
e.preventDefault();
setDragOverRow(null);
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (fromIndex !== toIndex && onReorder) {
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) {
setHoveredRow(null);
return;
}
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
setHoveredRow(null);
if (fromIndex === toIndex || !onReorder) return;
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) return;
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}, [onReorder, rowsWithEmpty, showAddRow]);
const handleDragEnd = useCallback(() => {
setHoveredRow(null);
setDragOverRow(null);
}, []);
const renderCell = useCallback((column, row, rowIndex) => {
@@ -323,109 +342,124 @@ const EditableTable = ({
);
}, [isLastEmptyRow, getRowError, handleValueChange]);
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
const virtuosoContext = useMemo(() => ({
reorderable,
reorderableRowCount,
isLastEmptyRow,
dragOverRow,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
onDragEnd: handleDragEnd
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
const fixedHeaderContent = useCallback(() => (
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
), [showCheckbox, checkboxLabel, columns, getColumnWidth, resizing, tableHeight, handleResizeStart, showDelete]);
const itemContent = useCallback((rowIndex, row) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<>
{showCheckbox && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</>
);
}, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]);
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(initialScroll / ROW_HEIGHT))).current;
return (
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
<div className="table-container" ref={tableRef} data-testid={testId}>
<table>
<thead>
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
</thead>
<tbody>
{rowsWithEmpty.map((row, rowIndex) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<tr
key={row.uid}
draggable={canDrag}
onDragStart={canDrag ? (e) => handleDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? handleDragEnd : undefined}
onMouseEnter={() => setHoveredRow(rowIndex)}
onMouseLeave={() => setHoveredRow(null)}
>
{showCheckbox && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
{hoveredRow === rowIndex && (
<>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</>
)}
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
<StyledWrapper
ref={wrapperRef}
data-testid={testId}
className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}
>
{scrollParent && (
<TableVirtuoso
ref={virtuosoRef}
className="table-container"
customScrollParent={scrollParent}
data={rowsWithEmpty}
components={{ TableRow }}
context={virtuosoContext}
defaultItemHeight={ROW_HEIGHT}
initialTopMostItemIndex={initialTopMostItemIndex}
totalListHeightChanged={handleTotalHeightChanged}
computeItemKey={(_, item) => item.uid}
fixedHeaderContent={fixedHeaderContent}
itemContent={itemContent}
/>
)}
</StyledWrapper>
);
};

View File

@@ -15,6 +15,7 @@ const Wrapper = styled.div`
overflow-y: auto;
border-radius: 8px;
border: solid 1px ${(props) => props.theme.border.border0};
transition: height 75ms cubic-bezier(0,1.12,.84,.64);
}
table {

View File

@@ -15,13 +15,16 @@ import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import { stripEnvVarUid } from 'utils/environments';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
const MIN_ROW_HEIGHT = 35;
const TableRow = React.memo(
({ children, item }) => (
<tr key={item.uid} data-testid={`env-var-row-${item.name}`}>
({ children, item, style, ...rest }) => (
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
{children}
</tr>
),
@@ -56,7 +59,19 @@ const EnvironmentVariablesTable = ({
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
const [tableHeight, setTableHeight] = useState(MIN_H);
const rowCount = (environment.variables?.length || 0) + 1;
const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT);
// We need to add <EditableTable/> component for env table
const [scroll, setScroll] = usePersistedState({
key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`,
default: 0
});
const scrollerRef = useRef(null);
const [scrollerEl, setScrollerEl] = useState(null);
scrollerRef.current = scrollerEl;
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(scroll / MIN_ROW_HEIGHT))).current;
useTrackScroll({ ref: scrollerRef, onChange: setScroll, initialValue: scroll, enabled: !!scrollerEl });
// Use environment UID as part of tableId so each environment has its own column widths
const tableId = `env-vars-table-${environment.uid}`;
@@ -136,17 +151,21 @@ const EnvironmentVariablesTable = ({
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
if (_collection) {
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
}
// When collection is null (global/workspace environments), populate process env
// variables from the active workspace so that {{process.env.X}} can resolve
if (!collection && activeWorkspace?.processEnvVariables) {
_collection.workspaceProcessEnvVariables = activeWorkspace.processEnvVariables;
}
const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables;
// `_collection` flows into every row's MultiLineEditor as the variable-resolution
// context. Without memoization, `cloneDeep(collection)` runs on every render —
// and Formik triggers a re-render on every keystroke, so a single env edit
// session can deep-clone the entire collection 100+ times. That's the
// dominant cost behind the test-budget flake.
const _collection = useMemo(() => {
const c = collection ? cloneDeep(collection) : {};
c.globalEnvironmentVariables = globalEnvironmentVariables;
if (!collection && workspaceProcessEnvVariables) {
c.workspaceProcessEnvVariables = workspaceProcessEnvVariables;
}
return c;
}, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables]);
const initialValues = useMemo(() => {
const vars = environment.variables || [];
@@ -483,6 +502,9 @@ const EnvironmentVariablesTable = ({
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
scrollerRef={setScrollerEl}
initialTopMostItemIndex={initialTopMostItemIndex}
overscan={Math.min(30, filteredVariables.length)}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
@@ -502,7 +524,6 @@ const EnvironmentVariablesTable = ({
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
@@ -535,7 +556,7 @@ const EnvironmentVariablesTable = ({
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
placeholder={!variable.name || (typeof variable.name === 'string' && variable.name.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
onBlur={() => {
@@ -560,7 +581,7 @@ const EnvironmentVariablesTable = ({
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
@@ -570,6 +591,19 @@ const EnvironmentVariablesTable = ({
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
// Append a new empty row when editing value on the last row
if (isLastRow) {
setTimeout(() => {
formik.setFieldValue(formik.values.length, {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}, false);
}, 0);
}
}}
onSave={handleSave}
/>
@@ -610,6 +644,8 @@ const EnvironmentVariablesTable = ({
/>
)}
{/* We should re-think of these buttons placement in component as we use TableVirtuoso which because of
these buttons renders at some transition: height 0.1s ease` */}
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">

View File

@@ -17,7 +17,11 @@ const EnvironmentListContent = ({
{environments && environments.length > 0 ? (
<>
<div className="environment-list">
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
<div
className={`dropdown-item no-environment ${!activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
onClick={() => onEnvironmentSelect(null)}
>
<span className="w-2 shrink-0" />
<span>No Environment</span>
</div>
<ToolHint

View File

@@ -117,6 +117,10 @@ const Wrapper = styled.div`
overflow: hidden;
}
.no-environment {
color: ${(props) => props.theme.colors.text.subtext0};
}
.environment-list {
flex: 1;
overflow-y: auto;

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {

View File

@@ -1,24 +1,35 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateFolderDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Documentation = ({ collection, folder }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [isEditing, setIsEditing] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const isEditing = focusedTab?.docsEditing || false;
const docs = folder.draft ? get(folder, 'draft.docs', '') : get(folder, 'root.docs', '');
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-docs-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
setIsEditing((prev) => !prev);
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
};
const onEdit = (value) => {
@@ -38,7 +49,7 @@ const Documentation = ({ collection, folder }) => {
}
return (
<StyledWrapper className="w-full relative flex flex-col">
<StyledWrapper className="w-full relative flex flex-col" ref={wrapperRef}>
<div className="editing-mode flex justify-between items-center flex-shrink-0" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
@@ -55,6 +66,8 @@ const Documentation = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
</div>
<div className="mt-6 flex-shrink-0">

View File

@@ -1,6 +1,6 @@
import styled from 'styled-components';
const Wrapper = styled.div`
const StyledWrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
@@ -53,4 +53,4 @@ const Wrapper = styled.div`
}
`;
export default Wrapper;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -13,6 +13,8 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -25,6 +27,9 @@ const Headers = ({ collection, folder }) => {
? get(folder, 'draft.request.headers', [])
: get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-headers-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
@@ -125,7 +130,7 @@ const Headers = ({ collection, folder }) => {
}
return (
<StyledWrapper className="w-full">
<StyledWrapper className="w-full" ref={wrapperRef}>
<div className="text-xs mb-4 text-muted">
Request headers that will be sent with every request inside this folder.
</div>
@@ -138,9 +143,10 @@ const Headers = ({ collection, folder }) => {
getRowError={getRowError}
columnWidths={folderHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
Bulk Edit
</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 { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -12,6 +13,7 @@ import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -39,13 +41,20 @@ const Script = ({ collection, folder }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Refresh CodeMirror when tab becomes visible
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `folder-pre-req-scroll-${folder.uid}`, default: 0 });
const [postResScroll, setPostResScroll] = usePersistedState({ key: `folder-post-res-scroll-${folder.uid}`, default: 0 });
// Refresh CodeMirror when tab becomes visible and restore scroll position.
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
}
}, 0);
@@ -102,34 +111,54 @@ const Script = ({ collection, folder }) => {
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
<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">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
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']}
/>
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
<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

@@ -1,19 +1,23 @@
import React from 'react';
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';
const Tests = ({ collection, folder }) => {
const dispatch = useDispatch();
const testsEditorRef = useRef(null);
const tests = folder.draft ? get(folder, 'draft.request.tests', '') : get(folder, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [testsScroll, setTestsScroll] = usePersistedState({ key: `folder-tests-scroll-${folder.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -30,17 +34,24 @@ const Tests = ({ collection, folder }) => {
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
<CodeEditor
collection={collection}
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']}
/>
<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

@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ folder, collection, vars, varType }) => {
const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -93,6 +93,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
getRowError={getRowError}
columnWidths={folderVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

@@ -1,10 +1,12 @@
import React from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Vars = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -12,15 +14,19 @@ const Vars = ({ collection, folder }) => {
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-vars-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="w-full flex flex-col">
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<div>
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
</div>
<div>
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import classnames from 'classnames';
import { updatedFolderSettingsSelectedTab } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
@@ -10,7 +10,7 @@ import Vars from './Vars';
import Documentation from './Documentation';
import Auth from './Auth';
import StatusDot from 'components/StatusDot';
import get from 'lodash/get';
import { hasEffectiveAuth } from 'utils/auth';
const FolderSettings = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -31,8 +31,11 @@ const FolderSettings = ({ collection, folder }) => {
const responseVars = folderRoot?.request?.vars?.res || [];
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(folderRoot, 'request.auth.mode');
const hasAuth = auth && auth !== 'none';
const folderAuthMode = folder?.draft?.request?.auth?.mode ?? folder?.root?.request?.auth?.mode;
const hasAuth = useMemo(
() => hasEffectiveAuth(collection, folder),
[folder, folderAuthMode, collection]
);
const setTab = (tab) => {
dispatch(
@@ -95,13 +98,13 @@ 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
</div>
</div>
<section className="flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="folder-settings-content flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</div>
</StyledWrapper>
);

View File

@@ -267,14 +267,16 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
uid: result.item.uid,
collectionUid: result.collectionUid,
requestPaneTab: getDefaultRequestPaneTab(result.item),
type: 'request'
type: result.item.type,
pathname: result.item.pathname
}));
}
} else if (result.type === SEARCH_TYPES.FOLDER) {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
type: 'folder-settings'
type: 'folder-settings',
pathname: result.item.pathname
}));
} else if (result.type === SEARCH_TYPES.COLLECTION) {
dispatch(addTab({

View File

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

View File

@@ -208,21 +208,35 @@ const Wrapper = styled.div`
outline-offset: 2px;
}
&:checked {
&:checked,
&:indeterminate {
background: ${(props) => props.theme.button2.color.primary.bg};
border-color: ${(props) => props.theme.button2.color.primary.border};
}
&::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid ${(props) => props.theme.button2.color.primary.text};
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
&:checked::after,
&:indeterminate::after {
content: '';
position: absolute;
}
&:checked::after {
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid ${(props) => props.theme.button2.color.primary.text};
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
&:indeterminate::after {
left: 2px;
top: 6px;
width: 10px;
height: 2px;
background: ${(props) => props.theme.button2.color.primary.text};
border-radius: 2px;
}
}
`;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import find from 'lodash/find';
import { IconLoader2, IconCloud } from '@tabler/icons';
import fastJsonFormat from 'fast-json-format';
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
import { updateApiSpecTabLeftPaneWidth } from 'providers/ReduxStore/slices/tabs';
/**
* Pretty-print JSON content for readable display. YAML content is returned as-is.
@@ -17,7 +20,17 @@ const prettyPrintSpec = (content) => {
}
};
const OpenAPISpecTab = ({ collection }) => {
const OpenAPISpecTab = ({ collection, tabUid }) => {
const dispatch = useDispatch();
const leftPaneWidth = useSelector((state) => {
const tab = find(state.tabs.tabs, (t) => t.uid === tabUid);
return tab?.apiSpecLeftPaneWidth ?? null;
});
const handleLeftPaneWidthChange = useCallback(
(w) => dispatch(updateApiSpecTabLeftPaneWidth({ uid: tabUid, apiSpecLeftPaneWidth: w })),
[dispatch, tabUid]
);
const [specContent, setSpecContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
@@ -26,6 +39,16 @@ const OpenAPISpecTab = ({ collection }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const sourceUrl = openApiSyncConfig?.sourceUrl;
// Latest env context for loadSpec's remote-fetch fallback. Kept out of
// loadSpec's deps so toggling a variable doesn't refire the spec load.
const envContextRef = useRef({});
envContextRef.current = {
activeEnvironmentUid: collection?.activeEnvironmentUid,
environments: collection?.environments,
runtimeVariables: collection?.runtimeVariables,
globalEnvironmentVariables: collection?.globalEnvironmentVariables
};
const loadSpec = useCallback(async () => {
setIsLoading(true);
setError(null);
@@ -42,12 +65,7 @@ const OpenAPISpecTab = ({ collection }) => {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl,
environmentContext: {
activeEnvironmentUid: collection.activeEnvironmentUid,
environments: collection.environments,
runtimeVariables: collection.runtimeVariables,
globalEnvironmentVariables: collection.globalEnvironmentVariables
}
environmentContext: envContextRef.current
});
if (fetchResult.content) {
setSpecContent(prettyPrintSpec(fetchResult.content));
@@ -64,7 +82,7 @@ const OpenAPISpecTab = ({ collection }) => {
} finally {
setIsLoading(false);
}
}, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
}, [collection?.pathname, collection?.uid, sourceUrl]);
useEffect(() => {
if (collection?.pathname) {
@@ -97,7 +115,12 @@ const OpenAPISpecTab = ({ collection }) => {
<span>Showing spec file from {sourceUrl}.</span>
</div>
)}
<SpecViewer content={specContent} readOnly />
<SpecViewer
content={specContent}
readOnly
leftPaneWidth={leftPaneWidth}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
</StyledWrapper>
);
};

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
IconCopy,
IconDotsVertical,
@@ -37,7 +36,7 @@ const OpenAPISyncHeader = ({
}
}, [sourceUrl, sourceIsLocal, collection.pathname]);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
const copyUrl = async () => {

View File

@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { countEndpoints } from '../utils';
import moment from 'moment';
@@ -43,7 +42,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
const activeError = error || reduxError;
const version = specMeta?.version;

View File

@@ -0,0 +1,46 @@
import React from 'react';
/**
* One virtualized row in the spec diff. Renders the side-by-side cells
* (left line number, left code, right line number, right code) for a normal
* row, or a single full-width cell for a hunk header.
*
* Paired del+ins rows render via dangerouslySetInnerHTML so the <del>/<ins>
* markup from the word-level diff cache shows through. Solo rows render as
* React text children and let React handle escaping.
*/
const DiffRow = ({ row, active, cache }) => {
if (!row) return null; // guard: Virtuoso race on rapid open/close or theme switch
if (row.leftKind === 'hunk') {
return (
<div className="diff-row diff-row-hunk">
<div className="diff-cell-hunk">{row.leftText}</div>
</div>
);
}
const isChange = row.leftKind === 'del' && row.rightKind === 'ins';
const wd = isChange ? cache.getWordDiff(row.leftText, row.rightText) : null;
const renderContent = (text, html) =>
html !== null
? <span className="diff-content" dangerouslySetInnerHTML={{ __html: html }} />
: <span className="diff-content">{text}</span>;
return (
<div className={`diff-row ${active ? 'diff-row-focused' : ''}`}>
<div className={`diff-cell-num diff-kind-${row.leftKind}`}>{row.leftNum ?? ''}</div>
<div className={`diff-cell-code diff-kind-${row.leftKind}`}>
<span className="diff-prefix">{row.leftKind === 'del' ? '-' : ' '}</span>
{renderContent(row.leftText, wd ? wd.left : null)}
</div>
<div className={`diff-cell-num diff-kind-${row.rightKind}`}>{row.rightNum ?? ''}</div>
<div className={`diff-cell-code diff-kind-${row.rightKind}`}>
<span className="diff-prefix">{row.rightKind === 'ins' ? '+' : ' '}</span>
{renderContent(row.rightText, wd ? wd.right : null)}
</div>
</div>
);
};
export default React.memo(DiffRow);

View File

@@ -0,0 +1,160 @@
import { buildRows, wrapIndex } from '../buildRows';
// Helpers to construct fixture "parsed" data in the shape Diff2Html.parse()
// actually returns. Line types come from the LineType enum
// ('context' | 'insert' | 'delete'), NOT the CSSLineClass enum
// ('d2h-cntx' | 'd2h-ins' | 'd2h-del'). Verified at
// packages/bruno-app/public/static/diff2Html.js:3172.
const ctx = (text, oldNum, newNum) => ({
type: 'context',
content: ` ${text}`,
oldNumber: oldNum,
newNumber: newNum
});
const del = (text, oldNum) => ({ type: 'delete', content: `-${text}`, oldNumber: oldNum });
const ins = (text, newNum) => ({ type: 'insert', content: `+${text}`, newNumber: newNum });
const block = (header, lines) => ({ header, lines });
const file = (...blocks) => [{ blocks }];
describe('buildRows', () => {
test('1. empty/missing input → empty rows and changeBlocks', () => {
expect(buildRows(null)).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows(undefined)).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows([])).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows([{ blocks: [] }])).toEqual({ rows: [], changeBlocks: [] });
});
test('2. all-context hunk → 0 change blocks, only ctx + hunk rows', () => {
const parsed = file(block('@@ -1,3 +1,3 @@', [ctx('a', 1, 1), ctx('b', 2, 2), ctx('c', 3, 3)]));
const { rows, changeBlocks } = buildRows(parsed);
expect(changeBlocks).toEqual([]);
expect(rows).toHaveLength(4); // 1 hunk + 3 ctx
expect(rows[0].leftKind).toBe('hunk');
expect(rows[1].leftKind).toBe('ctx');
expect(rows[1].leftText).toBe('a');
expect(rows[1].rightText).toBe('a');
expect(rows[1].leftNum).toBe(1);
expect(rows[1].rightNum).toBe(1);
});
test('3. pure-deletion run → del rows with empty placeholders on right', () => {
const parsed = file(
block('@@ -1,3 +1,1 @@', [ctx('keep', 1, 1), del('gone1', 2), del('gone2', 3)])
);
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(4); // 1 hunk + 1 ctx + 2 del rows
expect(rows[2].leftKind).toBe('del');
expect(rows[2].rightKind).toBe('empty');
expect(rows[2].leftText).toBe('gone1');
expect(rows[2].rightText).toBe('');
expect(rows[2].leftNum).toBe(2);
expect(rows[2].rightNum).toBeNull();
expect(rows[3].leftKind).toBe('del');
expect(rows[3].leftText).toBe('gone2');
// Two consecutive deletions form one block
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
});
test('4. pure-insertion run → empty placeholders on left, ins on right', () => {
const parsed = file(
block('@@ -1,1 +1,3 @@', [ctx('keep', 1, 1), ins('new1', 2), ins('new2', 3)])
);
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(4);
expect(rows[2].leftKind).toBe('empty');
expect(rows[2].rightKind).toBe('ins');
expect(rows[2].leftText).toBe('');
expect(rows[2].rightText).toBe('new1');
expect(rows[2].leftNum).toBeNull();
expect(rows[2].rightNum).toBe(2);
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
});
test('matched del+ins pair → paired row with leftKind=del, rightKind=ins', () => {
const parsed = file(block('@@ -1,1 +1,1 @@', [del('old', 1), ins('new', 1)]));
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(2); // hunk + 1 paired change row
// Paired row wears natural del/ins kinds — DiffRow detects this combo
// to run word-level diff. Matches GitHub's side-by-side convention
// (red left = deleted content, green right = inserted content).
expect(rows[1].leftKind).toBe('del');
expect(rows[1].rightKind).toBe('ins');
expect(rows[1].leftText).toBe('old');
expect(rows[1].rightText).toBe('new');
expect(rows[1].leftNum).toBe(1);
expect(rows[1].rightNum).toBe(1);
expect(changeBlocks).toEqual([{ startIdx: 1, endIdx: 1 }]);
});
test('5. multi-hunk diff → hunk rows insert correctly + blocks segment per change region', () => {
const parsed = file(
block('@@ -1,2 +1,2 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2)]),
block('@@ -10,2 +10,2 @@', [ctx('x', 10, 10), del('y', 11), ins('Y', 11)])
);
const { rows, changeBlocks } = buildRows(parsed);
// Block 1: hunk + ctx + 1 paired change = 3 rows
// Block 2: hunk + ctx + 1 paired change = 3 rows
expect(rows).toHaveLength(6);
expect(rows[0].leftKind).toBe('hunk');
expect(rows[3].leftKind).toBe('hunk');
// Two distinct change blocks (separated by hunk header reset)
expect(changeBlocks).toEqual([
{ startIdx: 2, endIdx: 2 },
{ startIdx: 5, endIdx: 5 }
]);
});
test('6. REGRESSION: change-block count matches expected counts for 3 fixture shapes', () => {
// The old DOM walker counted contiguous DOM rows containing
// .d2h-ins/.d2h-del/.d2h-change as one block. The new row-list walker
// must produce the same count for the same diff shape.
// Fixture A: small diff, one contiguous change region
const fixtureA = file(
block('@@ -1,4 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ctx('c', 3, 3)])
);
expect(buildRows(fixtureA).changeBlocks).toHaveLength(1);
// Fixture B: medium, two separate change regions in one hunk
const fixtureB = file(
block('@@ -1,7 +1,7 @@', [
ctx('a', 1, 1),
del('b', 2),
ins('B', 2),
ctx('c', 3, 3),
ctx('d', 4, 4),
del('e', 5),
ins('E', 5),
ctx('f', 6, 6)
])
);
expect(buildRows(fixtureB).changeBlocks).toHaveLength(2);
// Fixture C: multi-hunk with adjacent del+ins runs that form a single
// contiguous change region per hunk
const fixtureC = file(
block('@@ -1,3 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ins('C', 3)]),
block('@@ -10,4 +11,4 @@', [
ctx('x', 10, 11),
del('y', 11),
del('z', 12),
ins('Y', 12),
ins('Z', 13)
])
);
expect(buildRows(fixtureC).changeBlocks).toHaveLength(2);
});
});
describe('wrapIndex', () => {
test('7. wrap-around modulo handles negative and overflow', () => {
expect(wrapIndex(0, 5)).toBe(0);
expect(wrapIndex(4, 5)).toBe(4);
expect(wrapIndex(5, 5)).toBe(0);
expect(wrapIndex(6, 5)).toBe(1);
expect(wrapIndex(-1, 5)).toBe(4);
expect(wrapIndex(-6, 5)).toBe(4);
expect(wrapIndex(0, 0)).toBe(0);
expect(wrapIndex(3, 0)).toBe(0);
});
});

View File

@@ -0,0 +1,164 @@
/**
* Flatten Diff2Html's parsed unified-diff output into what the virtualized
* renderer needs:
*
* rows[] — one entry per visual row in the side-by-side layout
* (exactly what Virtuoso renders)
* changeBlocks[] — index ranges into rows[], drives Next/Prev navigation
*
* Row shape:
* { leftNum, leftText, leftKind, rightNum, rightText, rightKind }
* *Kind ∈ 'ctx' | 'del' | 'ins' | 'empty' | 'hunk'
*
* When a row has leftKind='del' AND rightKind='ins', DiffRow recognises it
* as a matched change and renders word-level highlights.
*/
// Diff2Html's parse() leaves the leading '+' / '-' / ' ' on each line's
// content. DiffRow renders that marker in its own styled span, so we strip
// it from the displayed text.
const stripLeadingMarker = (content) => (content || '').replace(/^[+\- ]/, '');
// Row factories — keep the row object shape consistent in one place.
const hunkRow = (header) => ({
leftKind: 'hunk',
rightKind: 'hunk',
leftText: header,
rightText: header,
leftNum: null,
rightNum: null
});
const contextRow = (line) => ({
leftKind: 'ctx',
rightKind: 'ctx',
leftText: stripLeadingMarker(line.content),
rightText: stripLeadingMarker(line.content),
leftNum: line.oldNumber ?? null,
rightNum: line.newNumber ?? null
});
const pairedChangeRow = (deletion, insertion) => ({
leftKind: 'del',
rightKind: 'ins',
leftText: stripLeadingMarker(deletion.content),
rightText: stripLeadingMarker(insertion.content),
leftNum: deletion.oldNumber ?? null,
rightNum: insertion.newNumber ?? null
});
const soloDeletionRow = (deletion) => ({
leftKind: 'del',
rightKind: 'empty',
leftText: stripLeadingMarker(deletion.content),
rightText: '',
leftNum: deletion.oldNumber ?? null,
rightNum: null
});
const soloInsertionRow = (insertion) => ({
leftKind: 'empty',
rightKind: 'ins',
leftText: '',
rightText: stripLeadingMarker(insertion.content),
leftNum: null,
rightNum: insertion.newNumber ?? null
});
export function buildRows(parsed) {
const rows = [];
if (!parsed || !Array.isArray(parsed) || parsed.length === 0) {
return { rows, changeBlocks: [] };
}
// Spec sync always produces a single-file diff; ignore any others.
const hunks = parsed[0]?.blocks || [];
// ── Pass 1: flatten each hunk's lines into visual rows ──
for (const hunk of hunks) {
if (hunk.header) rows.push(hunkRow(hunk.header));
const lines = hunk.lines || [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.type === 'context') {
rows.push(contextRow(line));
i++;
continue;
}
// Collect the next run of deletions, then the run of insertions that
// immediately follows. Pair them 1:1 into side-by-side change rows;
// any leftovers spill as solo rows.
//
// e.g. del A, del B, del C, ins X, ins Y
// → (A ↔ X) (B ↔ Y) (C ↔ ∅)
const deletions = [];
while (i < lines.length && lines[i].type === 'delete') {
deletions.push(lines[i]);
i++;
}
const insertions = [];
while (i < lines.length && lines[i].type === 'insert') {
insertions.push(lines[i]);
i++;
}
const pairCount = Math.min(deletions.length, insertions.length);
for (let p = 0; p < pairCount; p++) {
rows.push(pairedChangeRow(deletions[p], insertions[p]));
}
for (let p = pairCount; p < deletions.length; p++) {
rows.push(soloDeletionRow(deletions[p]));
}
for (let p = pairCount; p < insertions.length; p++) {
rows.push(soloInsertionRow(insertions[p]));
}
// Safety: skip unknown line types so the outer loop can't stall.
if (
i < lines.length
&& lines[i].type !== 'context'
&& lines[i].type !== 'delete'
&& lines[i].type !== 'insert'
) {
i++;
}
}
}
// ── Pass 2: group consecutive changed rows into navigation blocks ──
// Hunk headers and context rows each close the currently-active block.
const changeBlocks = [];
let currentBlock = null;
rows.forEach((row, idx) => {
const isChanged = row.leftKind === 'del' || row.rightKind === 'ins';
if (row.leftKind === 'hunk' || !isChanged) {
currentBlock = null;
return;
}
if (currentBlock) {
currentBlock.endIdx = idx;
} else {
currentBlock = { startIdx: idx, endIdx: idx };
changeBlocks.push(currentBlock);
}
});
return { rows, changeBlocks };
}
// Wrap-around modulo so Prev at block 0 jumps to the last block. JS's
// native `%` returns -1 for `-1 % 5`; the double-mod gives 4. Clamp to 0
// when there are no blocks at all.
export function wrapIndex(idx, length) {
if (length <= 0) return 0;
return ((idx % length) + length) % length;
}

View File

@@ -0,0 +1,55 @@
import { escapeHtml } from 'utils/response';
// Skip word-level diff on lines longer than this (Diff2Html default is 10k).
const MAX_HIGHLIGHT_LENGTH = 5000;
export function createHighlightCache() {
// Map of `${left}\x00${right}` → { left, right } HTML. The null byte separator safely delimits the pair.
const cache = new Map();
return {
// Word-level diff for a paired del+ins row. Returns { left, right } HTML
// with <del>/<ins> around changed words.
getWordDiff(leftContent, rightContent) {
const key = `${leftContent}\x00${rightContent}`;
const hit = cache.get(key);
if (hit !== undefined) return hit; // cache hit → skip the ~1-3ms recomputation
// Diff2Html ships as a global UMD bundle loaded from /public/static.
const D2H = typeof window !== 'undefined' && window.Diff2Html;
let result;
if (D2H && typeof D2H.diffHighlight === 'function') {
try {
// diffHighlight's internal parser expects each line to start with a
// prefix char (-, +, space) and strips it. We prepend '-' / '+' here
// purely to satisfy that input shape.
const out = D2H.diffHighlight(
`-${leftContent}`,
`+${rightContent}`,
false, // isCombined: standard two-way diff, not a git combined diff
{ matching: 'words', maxLineLengthHighlight: MAX_HIGHLIGHT_LENGTH }
);
// out.oldLine/newLine.content already has the <del>/<ins> markup we want.
result = {
left: out?.oldLine?.content ?? escapeHtml(leftContent),
right: out?.newLine?.content ?? escapeHtml(rightContent)
};
} catch {
// Malformed input or Diff2Html internal error — fall back so the row still renders.
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
}
} else {
// Diff2Html bundle hasn't loaded (test env, CSP, etc.) — escape only.
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
}
cache.set(key, result); // stored so Virtuoso remounts of this same row hit cache
return result;
},
// Empties the cache when a fresh diff replaces the current one.
clear() {
cache.clear();
}
};
}

View File

@@ -1,13 +1,21 @@
import { useRef, useEffect, useState } from 'react';
import { useTheme } from 'providers/Theme/index';
import { IconLoader2 } from '@tabler/icons';
import { Virtuoso } from 'react-virtuoso';
import { IconLoader2, IconChevronUp, IconChevronDown } from '@tabler/icons';
import Modal from 'components/Modal';
import StatusBadge from 'ui/StatusBadge';
import { buildRows, wrapIndex } from './buildRows';
import { createHighlightCache } from './highlightCache';
import DiffRow from './DiffRow';
const SpecDiffModal = ({ specDrift, onClose }) => {
const diffRef = useRef(null);
const { displayedTheme } = useTheme();
const virtuosoRef = useRef(null);
const [cache] = useState(createHighlightCache);
const [isRendering, setIsRendering] = useState(true);
const [parseError, setParseError] = useState(false);
const [rows, setRows] = useState([]);
const [changeBlocks, setChangeBlocks] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const addedCount = specDrift?.added?.length || 0;
const modifiedCount = specDrift?.modified?.length || 0;
@@ -17,54 +25,119 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
? `v${specDrift.storedVersion || '?'} → v${specDrift.newVersion}`
: null;
// Parse + build row list, deferred via setTimeout so the spinner paints first.
useEffect(() => {
const { Diff2Html } = window;
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
if (!Diff2Html || !specDrift?.unifiedDiff) {
setIsRendering(false);
return;
}
setIsRendering(true);
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
drawFileList: false,
matching: 'lines',
outputFormat: 'side-by-side',
synchronisedScroll: true,
highlight: true,
renderNothingWhenEmpty: false,
colorScheme: displayedTheme
setParseError(false);
// setTimeout yields to the browser so the spinner paints before parse blocks.
const timer = setTimeout(() => {
try {
const parsed = Diff2Html.parse(specDrift.unifiedDiff, {
outputFormat: 'side-by-side',
matching: 'lines'
});
const built = buildRows(parsed);
setRows(built.rows);
setChangeBlocks(built.changeBlocks);
setCurrentIndex(0);
cache.clear();
} catch (err) {
console.error('SpecDiffModal: failed to parse unified diff', err);
setParseError(true);
}
setIsRendering(false);
}, 0);
return () => clearTimeout(timer);
}, [specDrift?.unifiedDiff, cache]);
const goToChange = (idx) => {
if (!changeBlocks.length) return;
const nextIndex = wrapIndex(idx, changeBlocks.length);
const targetBlock = changeBlocks[nextIndex];
const fromBlock = changeBlocks[currentIndex];
const gap = fromBlock ? Math.abs(targetBlock.startIdx - fromBlock.startIdx) : 0;
virtuosoRef.current?.scrollToIndex({
index: targetBlock.startIdx,
align: 'center',
behavior: gap > 500 ? 'auto' : 'smooth'
});
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
diffRef.current.innerHTML = diffHtml;
setIsRendering(false);
}, [displayedTheme, specDrift?.unifiedDiff]);
setCurrentIndex(nextIndex);
};
const activeBlock = changeBlocks[currentIndex] || null;
const renderItem = (index) => (
<DiffRow
row={rows[index]}
active={!!activeBlock && index >= activeBlock.startIdx && index <= activeBlock.endIdx}
cache={cache}
/>
);
const showNav = !!specDrift?.unifiedDiff && !parseError;
const changeCount = changeBlocks.length;
const counterLabel
= changeCount === 0 ? 'No changes' : `${currentIndex + 1} of ${changeCount} changes`;
return (
<Modal
size="xl"
title="Spec Diff"
hideFooter
handleCancel={onClose}
>
<Modal size="xl" title="Spec Diff" hideFooter handleCancel={onClose}>
<div className="spec-diff-modal">
<div className="spec-diff-badges">
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>
<div className="spec-diff-header">
<div className="spec-diff-header-left">
<div className="spec-diff-badges">
<div>Endpoint Changes:</div>
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>
<p className="spec-diff-subtitle">
{specDrift?.storedSpecMissing
? 'The current spec file is missing. The full remote spec is shown below.'
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
</p>
<p className="spec-diff-subtitle">
{specDrift?.storedSpecMissing
? 'The current spec file is missing. The full remote spec is shown below.'
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
</p>
</div>
{showNav && (
<div className="spec-diff-nav">
<span className="spec-diff-nav-counter">{counterLabel}</span>
<div className="spec-diff-nav-buttons">
<button
type="button"
className="spec-diff-nav-btn"
onClick={() => goToChange(currentIndex - 1)}
disabled={changeCount === 0}
title="Previous change"
>
<IconChevronUp size={14} strokeWidth={1.75} /> Previous
</button>
<button
type="button"
className="spec-diff-nav-btn"
onClick={() => goToChange(currentIndex + 1)}
disabled={changeCount === 0}
title="Next change"
>
<IconChevronDown size={14} strokeWidth={1.75} /> Next
</button>
</div>
</div>
)}
</div>
<div className="spec-diff-body">
<div className="text-diff-container">
{specDrift?.unifiedDiff ? (
<>
<div className="diff-column-headers">
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
<span className="diff-column-label">
{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}
</span>
<span className="diff-column-label">Updated Spec</span>
</div>
{isRendering && (
@@ -73,7 +146,25 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
<span>Loading diff...</span>
</div>
)}
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
{!isRendering && parseError && (
<div className="text-diff-empty">
Diff couldn&apos;t be rendered. Please file an issue with the spec.
</div>
)}
{!isRendering && !parseError && rows.length > 0 && (
<Virtuoso
ref={virtuosoRef}
totalCount={rows.length}
itemContent={renderItem}
// Must match .diff-row min-height in OpenAPISyncTab/StyledWrapper.js
fixedItemHeight={18}
increaseViewportBy={400}
style={{ height: '100%' }}
/>
)}
{!isRendering && !parseError && rows.length === 0 && (
<div className="text-diff-empty">No changes to display.</div>
)}
</>
) : (
<div className="text-diff-empty">No text diff available.</div>

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};
}
}
}
@@ -1503,143 +1503,154 @@ const StyledWrapper = styled.div`
.text-diff-container {
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.border.border1};
overflow: auto;
overflow: hidden;
display: flex;
flex-direction: column;
background: ${(props) => props.theme.bg};
.diff-column-headers {
display: flex;
display: grid;
grid-template-columns: 9ch 1fr 9ch 1fr;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
position: sticky;
top: 0;
z-index: 2;
background: ${(props) => props.theme.bg};
flex-shrink: 0;
.diff-column-label {
flex: 1;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
grid-column: span 2;
&:first-child {
border-right: 1px solid ${(props) => props.theme.border.border1};
&:last-child {
border-left: 1px solid ${(props) => props.theme.border.border1};
}
}
}
.d2h-wrapper {
background-color: ${(props) => props.theme.bg} !important;
/* The Virtuoso scroll container fills the rest of the modal body. */
> div[data-testid='virtuoso-scroller'],
> div:last-child {
flex: 1 1 auto;
min-height: 0;
}
/* Active block gets a persistent 3px yellow bar down the left edge. */
.diff-row {
display: grid;
grid-template-columns: 9ch 1fr 9ch 1fr;
font-family: 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
/* Must match Virtuoso's fixedItemHeight in SpecDiffModal/index.js */
min-height: 18px;
color: ${(props) => props.theme.text};
font-variant-ligatures: none;
font-feature-settings: 'liga' 0, 'calt' 0;
}
.d2h-file-wrapper {
border: none;
border-radius: 0;
margin-bottom: 0;
/* Vertical divider between the two side-by-side panels. Applied to the
third grid cell (right-side line number), aligned with the header's
existing border-right on the "Current Spec" label. */
.diff-row > *:nth-child(3) {
border-left: 1px solid ${(props) => props.theme.border.border1};
}
.d2h-file-header {
display: none;
.diff-row.diff-row-focused > .diff-cell-num:first-child {
box-shadow: inset 3px 0 0 ${(props) => props.theme.colors.text.yellow};
}
.d2h-files-diff {
width: 100%;
.diff-row.diff-row-focused > .diff-cell-num {
color: ${(props) => props.theme.text};
font-weight: 600;
}
.d2h-file-side-diff:first-child {
border-right: 1px solid ${(props) => props.theme.border.border1};
.diff-cell-num {
padding: 0 0.5em;
text-align: right;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.diff-kind-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
}
&.diff-kind-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
}
&.diff-kind-empty {
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
}
}
.d2h-code-side-linenumber {
background: transparent !important;
position: static !important;
.diff-cell-code {
display: flex;
min-width: 0;
padding: 0 0.5em;
white-space: pre;
overflow: hidden;
&.diff-kind-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
}
&.diff-kind-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
}
&.diff-kind-empty {
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
}
}
.d2h-diff-tbody {
tr td { border: none !important; }
.diff-prefix {
width: 1em;
flex-shrink: 0;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
}
.d2h-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent) !important;
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
.diff-content {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent);
text-decoration: none;
}
ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent);
text-decoration: none;
}
}
.d2h-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent) !important;
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
}
/* Hunk row must be exactly 18px so Virtuoso's fixedItemHeight is
accurate. Borders would add 2px; we use inset box-shadow to get the
visual top/bottom rule without consuming layout space. Vertical
padding removed for the same reason. */
.diff-row-hunk {
grid-template-columns: 1fr;
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.08)};
color: ${(props) => props.theme.colors.text.muted};
box-shadow:
inset 0 1px 0 ${(props) => props.theme.border.border1},
inset 0 -1px 0 ${(props) => props.theme.border.border1};
.d2h-file-diff .d2h-ins.d2h-change {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 25%, transparent) !important;
}
.d2h-file-diff .d2h-del.d2h-change {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent) !important;
}
.d2h-code-line ins,
.d2h-code-side-line ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
text-decoration: none;
}
.d2h-code-line del,
.d2h-code-side-line del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
text-decoration: none;
}
.d2h-code-line,
.d2h-code-side-line {
color: ${(props) => props.theme.text} !important;
word-break: break-all;
}
.d2h-code-line-ctn {
word-break: break-all;
}
.d2h-tag {
font-size: 9px;
font-weight: 500;
padding: 1px 5px;
border-radius: ${(props) => props.theme.border.radius.sm};
text-transform: uppercase;
letter-spacing: 0.02em;
border: none;
}
.d2h-changed-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 15%, transparent);
color: ${(props) => props.theme.colors.text.warning};
}
.d2h-added-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 13%, transparent);
color: ${(props) => props.theme.colors.text.green};
}
.d2h-deleted-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 13%, transparent);
color: ${(props) => props.theme.colors.text.danger};
}
.d2h-renamed-tag,
.d2h-moved-tag {
display: none;
}
.d2h-file-wrapper,
.d2h-file-diff,
.d2h-code-wrapper,
.d2h-diff-table,
.d2h-code-line,
.d2h-code-side-line,
.d2h-code-line-ctn,
.d2h-code-linenumber,
.d2h-code-side-linenumber {
font-family: 'Fira Code', monospace !important;
font-size: 12px !important;
.diff-cell-hunk {
padding: 0 0.75em;
font-family: 'Fira Code', monospace;
font-size: 11px;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
@@ -1661,6 +1672,15 @@ const StyledWrapper = styled.div`
}
.spec-diff-modal {
.spec-diff-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 1rem;
}
.spec-diff-badges {
display: flex;
gap: 0.5rem;
@@ -1671,12 +1691,50 @@ const StyledWrapper = styled.div`
.spec-diff-subtitle {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
margin: 0 0 0.75rem 0;
}
.spec-diff-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
.spec-diff-nav-counter {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.spec-diff-nav-buttons {
display: flex;
gap: 0.5rem;
}
.spec-diff-nav-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
background: none;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.text};
cursor: pointer;
&:hover:not(:disabled) {
background: ${(props) => props.theme.background.surface1};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.spec-diff-body {
.text-diff-container {
max-height: calc(80vh - 140px);
height: calc(80vh - 140px);
}
}
}

View File

@@ -15,7 +15,7 @@ import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRo
import ConfirmSyncModal from '../ConfirmSyncModal';
import SpecDiffModal from '../SpecDiffModal';
import Help from 'components/Help';
import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
import { setReviewDecision, setReviewDecisions } from 'providers/ReduxStore/slices/openapi-sync';
/**
* Categorize remoteDrift endpoints using three-way merge.
@@ -87,9 +87,20 @@ const SyncReviewPage = ({
onApplySync
}) => {
const dispatch = useDispatch();
const tabUiState = useSelector(selectTabUiState(collectionUid));
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
const [showConfirmation, setShowConfirmation] = useState(false);
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
// setTimeout lets the button's spinner paint before the modal mounts —
// without it, React batches both state updates and the spinner never shows.
const handleOpenSpecDiff = () => {
setIsOpeningSpecDiff(true);
setTimeout(() => {
setShowSpecDiffModal(true);
setIsOpeningSpecDiff(false);
}, 0);
};
const { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints } = useMemo(() => {
if (!remoteDrift) {
@@ -228,8 +239,17 @@ const SyncReviewPage = ({
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
<div className="bulk-actions">
{specDrift?.unifiedDiff && (
<button className="bulk-btn" onClick={() => setShowSpecDiffModal(true)}>
<IconArrowsDiff size={12} /> View Spec Diff
<button
className="bulk-btn"
onClick={handleOpenSpecDiff}
disabled={isOpeningSpecDiff || showSpecDiffModal}
>
{isOpeningSpecDiff ? (
<IconLoader2 size={12} className="animate-spin" />
) : (
<IconArrowsDiff size={12} />
)}{' '}
View Spec Diff
</button>
)}
{decidableEndpoints.length > 0 && (

View File

@@ -1,9 +1,16 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector, useStore } from 'react-redux';
import toast from 'react-hot-toast';
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
clearCollectionState,
setCollectionUpdate,
setStoredSpec,
setStoredSpecMeta,
setDrift
} from 'providers/ReduxStore/slices/openapi-sync';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isHttpUrl } from 'utils/url/index';
import { flattenItems } from 'utils/collections/index';
@@ -19,19 +26,23 @@ const useOpenAPISync = (collection) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [fileNotFound, setFileNotFound] = useState(false);
const [specDrift, setSpecDrift] = useState(null);
// Collection drift state
const [collectionDrift, setCollectionDrift] = useState(null);
const [remoteDrift, setRemoteDrift] = useState(null);
const [isDriftLoading, setIsDriftLoading] = useState(false);
const [storedSpec, setStoredSpec] = useState(null);
const tabs = useSelector((state) => state.tabs.tabs);
const drift = useSelector((state) => state.openapiSync?.drift?.[collection.uid] || null);
const specDrift = drift?.specDrift || null;
const collectionDrift = drift?.collectionDrift || null;
const remoteDrift = drift?.remoteDrift || null;
const storedSpec = useSelector((state) => state.openapiSync?.storedSpec?.[collection.uid] || null);
const updateDrift = (patch) => dispatch(setDrift({ collectionUid: collection.uid, patch }));
// useStore: tabs are read only inside handlers — useSelector would re-render on every tab change.
const store = useStore();
const isConfigured = !!openApiSyncConfig?.sourceUrl;
const updateStoredSpec = (spec) => {
setStoredSpec(spec);
dispatch(setStoredSpec({ collectionUid: collection.uid, spec }));
dispatch(setStoredSpecMeta({
collectionUid: collection.uid,
title: spec?.info?.title || null,
@@ -72,6 +83,7 @@ const useOpenAPISync = (collection) => {
const openEndpointInTab = (endpointId) => {
const itemUid = endpointUidMap[endpointId];
if (!itemUid) return;
const tabs = store.getState().tabs?.tabs || [];
const existingTab = tabs.find((t) => t.uid === itemUid);
if (existingTab) {
dispatch(focusTab({ uid: itemUid }));
@@ -81,19 +93,18 @@ const useOpenAPISync = (collection) => {
uid: itemUid,
collectionUid: collection.uid,
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
type: 'request'
type: item?.type ?? 'request'
}));
}
};
const prevItemCountRef = useRef(httpItemCount);
const isDriftLoadingRef = useRef(false);
const specDriftRef = useRef(specDrift);
const loadCollectionDrift = async ({ clear = false } = {}) => {
if (isDriftLoadingRef.current && !clear) return;
isDriftLoadingRef.current = true;
if (clear) setCollectionDrift(null);
if (clear) updateDrift({ collectionDrift: null });
setIsDriftLoading(true);
try {
const { ipcRenderer } = window;
@@ -102,7 +113,7 @@ const useOpenAPISync = (collection) => {
});
if (!result.error) {
setCollectionDrift(result);
updateDrift({ collectionDrift: result, itemCountAtLastFetch: httpItemCount });
}
} catch (err) {
console.error('Error loading collection drift:', err);
@@ -122,9 +133,7 @@ const useOpenAPISync = (collection) => {
setIsLoading(true);
setError(null);
setFileNotFound(false);
setSpecDrift(null);
setRemoteDrift(null);
setCollectionDrift(null);
updateDrift({ fetching: true });
try {
const { ipcRenderer } = window;
@@ -146,14 +155,13 @@ const useOpenAPISync = (collection) => {
return;
}
setSpecDrift(result);
updateDrift({ specDrift: result, lastChecked: Date.now() });
updateStoredSpec(result.storedSpec || null);
// Update Redux store so toolbar status stays in sync
dispatch(setCollectionUpdate({
collectionUid: collection.uid,
hasUpdates: result.isValid !== false && result.hasChanges,
diff: result,
error: result.isValid === false ? result.error : null
}));
@@ -167,7 +175,7 @@ const useOpenAPISync = (collection) => {
console.error('Error computing remote drift:', remoteComparison.error);
setError(remoteComparison.error);
} else {
setRemoteDrift(remoteComparison);
updateDrift({ remoteDrift: remoteComparison });
}
}
@@ -181,24 +189,25 @@ const useOpenAPISync = (collection) => {
dispatch(setCollectionUpdate({
collectionUid: collection.uid,
hasUpdates: false,
diff: null,
error: formatIpcError(err) || 'Failed to check for updates'
}));
} finally {
updateDrift({ fetching: false });
setIsLoading(false);
}
};
useEffect(() => {
if (isConfigured) {
if (isConfigured && !drift?.specDrift && !drift?.fetching) {
checkForUpdates();
}
}, [isConfigured]);
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
// Reload drift when the collection's HTTP item count differs from what was recorded at the last fetch.
useEffect(() => {
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
prevItemCountRef.current = httpItemCount;
if (!isConfigured) return;
const cachedCount = drift?.itemCountAtLastFetch;
if (cachedCount !== undefined && cachedCount !== httpItemCount && !drift?.fetching) {
loadCollectionDrift();
}
}, [httpItemCount, isConfigured]);
@@ -245,7 +254,7 @@ const useOpenAPISync = (collection) => {
});
if (result.isValid === false) {
setSpecDrift(result);
updateDrift({ specDrift: result });
setError(result.error);
return;
}
@@ -263,15 +272,15 @@ const useOpenAPISync = (collection) => {
// Check if collection already matches the spec
if (result.newSpec) {
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
const initialDrift = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
compareSpec: result.newSpec
});
const isInSync = !drift.error
&& (!drift.missing || drift.missing.length === 0)
&& (!drift.modified || drift.modified.length === 0)
&& (!drift.localOnly || drift.localOnly.length === 0);
const isInSync = !initialDrift.error
&& (!initialDrift.missing || initialDrift.missing.length === 0)
&& (!initialDrift.modified || initialDrift.modified.length === 0)
&& (!initialDrift.localOnly || initialDrift.localOnly.length === 0);
if (isInSync) {
// Collection matches — save spec file silently to complete setup
@@ -299,15 +308,12 @@ const useOpenAPISync = (collection) => {
deleteSpecFile: true
});
setSourceUrl('');
setSpecDrift(null);
setCollectionDrift(null);
setRemoteDrift(null);
setStoredSpec(null);
// Clear Redux state for this collection
dispatch(clearCollectionState({ collectionUid: collection.uid }));
// Close the openapi-spec tab if open (spec file no longer exists)
const tabs = store.getState().tabs?.tabs || [];
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
if (specTab) {
dispatch(closeTabs({ tabUids: [specTab.uid] }));
@@ -337,7 +343,7 @@ const useOpenAPISync = (collection) => {
compareSpec: currentSpecDrift.newSpec
});
if (!remoteComparison.error) {
setRemoteDrift(remoteComparison);
updateDrift({ remoteDrift: remoteComparison });
}
} catch (err) {
console.error('Error reloading remote drift:', err);

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,6 @@ const StyledWrapper = styled.div`
.section-divider {
height: 1px;
background: ${(props) => props.theme.input.border};
margin: 10px 0;
}
.tables-container {
@@ -75,7 +74,7 @@ const StyledWrapper = styled.div`
thead {
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
background: ${(props) => props.theme.table.striped};
user-select: none;
td {
@@ -100,9 +99,8 @@ const StyledWrapper = styled.div`
tr {
transition: background 0.1s ease;
height: 30px;
td {
padding: 0 10px !important;
padding: 0px 10px !important;
border: none !important;
vertical-align: middle;
background: transparent;
@@ -111,7 +109,7 @@ const StyledWrapper = styled.div`
}
tr:hover:not(.row-editing) td {
background: ${(props) => props.theme.sidebar.bg};
background: ${(props) => props.theme.background.surface0};
cursor: pointer;
}
@@ -120,7 +118,7 @@ const StyledWrapper = styled.div`
}
tr.section-heading-row td {
font-weight: 600;
font-weight: 700;
padding: 6px 10px !important;
user-select: none;
}
@@ -131,8 +129,28 @@ const StyledWrapper = styled.div`
}
tr.section-last-row td {
border-bottom: none !important;
}
tr.section-spacer-row {
height: 8px;
pointer-events: none;
}
tr.section-spacer-row td {
padding: 0 !important;
height: 8px;
line-height: 8px;
font-size: 0;
background: transparent !important;
border: none !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
}
tr.section-spacer-row:hover td {
background: transparent !important;
cursor: default;
}
}
.keybinding-row {
@@ -180,7 +198,7 @@ const StyledWrapper = styled.div`
}
.shortcut-input--editing {
outline: 1px solid #E4AE49;
outline: 1px solid ${(props) => props.theme.status.warning.border};
border-radius: 4px;
min-width: 100%;
max-width: 100%;
@@ -189,7 +207,7 @@ const StyledWrapper = styled.div`
}
.shortcut-input--error.shortcut-input--editing {
outline: 1px solid #CE4F3B;
outline: 1px solid ${(props) => props.theme.status.danger.border};
min-width: 100%;
max-width: 100%;
}
@@ -220,39 +238,41 @@ const StyledWrapper = styled.div`
align-items: center;
justify-content: center;
min-width: 20px;
height: 22px;
height: 20px;
padding: 2px;
border-radius: 3px;
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.input.color};
font-size: 12px;
font-size: 10px;
font-weight: 500;
line-height: 1;
}
tbody tr.row-success td {
background: #2E8A540F;
tbody tr.row-success td,
tbody tr.row-success:hover td {
background: ${(props) => props.theme.status.success.background} !important;
}
tbody tr.row-error td {
background: #D32F2F0F;
tbody tr.row-error td,
tbody tr.row-error:hover td {
background: ${(props) => props.theme.status.danger.background} !important;
}
.success-icon {
color: #2E8A54;
color: ${(props) => props.theme.status.success.text};
display: inline-flex;
align-items: center;
}
.error-icon {
color: #CE4F3B;
color: ${(props) => props.theme.status.danger.text};
display: inline-flex;
align-items: center;
}
.input-error-icon {
color: #CE4F3B;
color: ${(props) => props.theme.status.danger.text};
display: inline-flex;
align-items: center;
margin-left: auto;
@@ -294,6 +314,11 @@ const StyledWrapper = styled.div`
border-radius: 6px;
padding: 0px 6px;
cursor: pointer;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.action-btn {

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState, useEffect } from 'react';
import React, { useMemo, useRef, useState, useEffect, Fragment } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -10,6 +10,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js';
import { Tooltip } from 'react-tooltip';
import ToggleSwitch from 'components/ToggleSwitch/index';
import toast from 'react-hot-toast';
const SEP = '+bind+';
const getOS = () => (isMacOS() ? 'mac' : 'windows');
@@ -82,10 +83,10 @@ const renderDisplayValue = (displayValue, os) => {
return (
<span className="shortcut-pills">
{parsed.map((keysArr, index) => (
<React.Fragment key={index}>
<Fragment key={index}>
{index > 0 && <span className="shortcut-separator"> - </span>}
{renderKeycaps(keysArr, os)}
</React.Fragment>
</Fragment>
))}
</span>
);
@@ -218,23 +219,21 @@ const RESERVED_BY_OS = {
comboSignature(['f12']) // Dashboard (older macOS)
]),
windows: new Set([
// System-level shortcuts (intercepted by Windows before reaching the app)
comboSignature(['alt', 'tab']),
comboSignature(['alt', 'shift', 'tab']),
comboSignature(['alt', 'f4']),
comboSignature(['f1']), // Windows Help
comboSignature(['alt', 'esc']),
comboSignature(['alt', 'space']),
comboSignature(['ctrl', 'alt', 'delete']),
comboSignature(['command', 'l']),
comboSignature(['command', 'd']),
comboSignature(['command', 'e']),
comboSignature(['command', 'r']),
comboSignature(['command', 'i']),
comboSignature(['command', 's']),
comboSignature(['command', 'a']),
comboSignature(['command', 'x']),
comboSignature(['command', 'm']),
comboSignature(['command', 'tab']),
comboSignature(['ctrl', 'shift', 'esc']),
// Function keys
comboSignature(['f1']), // Windows Help
comboSignature(['f11']), // Fullscreen toggle
comboSignature(['f12']), // DevTools
// Undo/Redo - standard text editing shortcuts that browsers handle natively
comboSignature(['ctrl', 'z']),
comboSignature(['ctrl', 'y']),
comboSignature(['ctrl', 'shift', 'z']),
// Toggle Developer Tools
comboSignature(['ctrl', 'shift', 'i'])
@@ -493,7 +492,7 @@ const Keybindings = () => {
if (buildUsedSignatures(action).has(sig)) {
return {
code: ERROR.DUPLICATE,
message: 'That shortcut is already in use.'
message: 'This shortcut is already in use.'
};
}
@@ -562,9 +561,24 @@ const Keybindings = () => {
return next;
});
persistToPreferences(action, def);
// Remove the entry from user preferences entirely so falls back to default.
// This also keeps `hasCustomizedKeybindings` accurate.
const nextKeyBindings = { ...(preferences?.keyBindings || {}) };
delete nextKeyBindings[action];
const updatedPreferences = {
...preferences,
keyBindings: nextKeyBindings
};
dispatch(savePreferences(updatedPreferences));
};
const hasCustomizedKeybindings = useMemo(() => {
const userKeyBindings = preferences?.keyBindings || {};
return Object.keys(userKeyBindings).length > 0;
}, [preferences?.keyBindings]);
const resetAllKeybindings = () => {
const updatedPreferences = {
...preferences,
@@ -572,6 +586,7 @@ const Keybindings = () => {
};
dispatch(savePreferences(updatedPreferences));
toast.success('All shortcuts have been reset to default');
};
const startEditing = (action) => {
@@ -799,6 +814,7 @@ const Keybindings = () => {
onClick={resetAllKeybindings}
className="reset-btn"
data-testid="reset-all-keybindings-btn"
disabled={!hasCustomizedKeybindings}
>
Reset Default
</button>
@@ -817,7 +833,7 @@ const Keybindings = () => {
</thead>
<tbody>
{groupedKeyMappings.map((section, sectionIndex) => (
<React.Fragment key={section.heading}>
<Fragment key={section.heading}>
<tr className="section-heading-row">
<td colSpan={2}>{section.heading}</td>
</tr>
@@ -946,7 +962,12 @@ const Keybindings = () => {
</tr>
);
})}
</React.Fragment>
{sectionIndex < groupedKeyMappings.length - 1 && (
<tr className="section-spacer-row" aria-hidden="true">
<td colSpan={2}>&nbsp;</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -9,6 +9,8 @@ import SingleLineEditor from 'components/SingleLineEditor';
import AssertionOperator from './AssertionOperator';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const unaryOperators = [
'isEmpty',
@@ -55,6 +57,9 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator);
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-assert-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
@@ -166,7 +171,7 @@ const Assertions = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full">
<StyledWrapper className="w-full" ref={wrapperRef}>
<EditableTable
tableId="assertions"
columns={columns}
@@ -178,6 +183,7 @@ const Assertions = ({ item, collection }) => {
testId="assertions-table"
columnWidths={assertionsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
initialScroll={scroll}
/>
</StyledWrapper>
);

View File

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

View File

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

View File

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

View File

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

View File

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

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