Compare commits

...

166 Commits
v3.4.0 ... main

Author SHA1 Message Date
sachin-bruno
bf3abec50e CDN docs url changes (#8466)
Co-authored-by: bruno-sachin <bruno-sachin@brunos-MacBook-Air.local>
2026-07-03 00:34:11 +05:30
gopu-bruno
eca0939f31 fix(ai): render AI assist popup via portal to prevent clipping by devtools (#8463) 2026-07-02 16:07:42 +05:30
naman-bruno
408aef9eef feat(ai): add security preferences management to AI settings (#8453) 2026-07-02 14:42:48 +05:30
sharan-bruno
022426a3ab fix(import): linting issue in Import fails for gRPC request without URL in exported collection (#8462) 2026-07-02 14:33:44 +05:30
Utkarsh
bd271ffad8 feat(workspaces): dispatch closeAiSidebar action on workspace switch (#8451) 2026-07-02 13:43:56 +05:30
prateek-bruno
6f96dfd58c fix: remove the v4-migration legacy key from local storage (#8353) 2026-07-02 12:55:36 +05:30
naman-bruno
488c9f2444 feat(AI): add stop generation functionality and improve UI interactions (#8452) 2026-07-02 01:40:28 +05:30
Sid
99538283a3 chore: update lodash to version 4.18.1 across all packages (#8455) 2026-07-02 01:26:38 +05:30
sachin-bruno
9157eb4190 feat:Added collection name and minor UI changes to the generate doc modal (#8391)
* feat:Added collection name and minor UI changes to the generate doc modal

* feat:Removed the test file

* feat: updated the version number changes and test cases

* Comments fixed

---------

Co-authored-by: bruno-sachin <bruno-sachin@brunos-MacBook-Air.local>
2026-07-01 23:37:55 +05:30
adwait-bruno
7b155c0e0a fix:serialize object variable values before copying to clipboard (#8362)
* fix:serialize object variable values before copying to clipboard

* fix:guard JSON.stringify against circular and non-serializable object values

* refactor:added string helper function

* test:added playwright test

---------

Co-authored-by: Adwait Aayush <adwaitaayush@Adwaits-MacBook-Air.local>
2026-07-01 20:19:17 +05:30
sharan-bruno
4f31e9b073 fix(import): Import fails for gRPC request without URL in exported collection (#8367)
* fix(import): Import fails for gRPC request without URL in exported collection

* changed the approach for import request with missing url

* addressed review comment

* added a fixture for http example request

* added an assertion to check whether the request is being imported or not

* added an assertion to check whether the request is being imported or not

* refactored test scripts
2026-07-01 14:37:23 +05:30
Sid
ed468dba88 feat: enhance multipart-form file handling by filtering empty values in parser and stringifier (#8444) 2026-07-01 13:21:49 +05:30
sanish chirayath
4ab68fc71f feat(cli): persist env, global-env, and collection var changes from scripts (#8387)
* feat(variables): implement variable persistence and updates for environment and collection files

- Introduced new utility functions for applying and persisting variable updates, enhancing the management of environment and collection variables.
- Updated the run command to include descriptors for environment files, allowing for better tracking of variable changes.
- Enhanced the runSingleRequest function to synchronize variable updates and persist changes to the appropriate files.
- Added comprehensive tests to ensure the correct functionality of variable merging and persistence logic across different file formats.

* fix(variables): refine YAML handling and data type inference for environment variables

- Updated the run command to exclusively recognize '.yml' files, removing support for '.yaml' extensions.
- Enhanced the persistence logic to correctly handle data type inference for newly added environment and collection variables.
- Added comprehensive tests to validate the correct writing of variables to YAML and .bru files, ensuring accurate data type preservation and updates.
- Improved the handling of existing data types when variables are modified, ensuring consistency across different formats.

* feat(tests): add integration tests for typed variable persistence in CLI

- Introduced a new test suite to validate the persistence of typed environment and collection variables set via scripts in the CLI.
- Implemented a local server to facilitate HTTP requests during tests, ensuring accurate simulation of variable management.
- Enhanced the test logic to verify that the correct data type annotations are written to both .bru and YAML files after CLI execution.
- Added comprehensive assertions to confirm the expected disk state for both global and collection variables across different sandboxes.

* refactor(runSingleRequest): remove synchronization of variable updates after post response script execution

- Eliminated the syncVariableUpdates call from the runSingleRequest function, streamlining the variable handling process.
- Adjusted the logic to focus on executing post response variables without unnecessary synchronization, improving performance and clarity.

* feat(env-vars): implement transient environment variable handling and improve persistence resilience

- Added support for transient environment variable overrides via the `--env-var` CLI option, ensuring these values are not persisted to disk.
- Enhanced the `mergeScriptVarsIntoEnvList` function to prevent overridden variables from being written back, preserving existing entries.
- Updated the `persistVariableUpdates` function to handle potential disk write errors gracefully, logging warnings instead of failing the execution.
- Introduced comprehensive tests to validate the behavior of transient variables and ensure correct persistence logic under various scenarios.

* fix(persist-variables): enhance variable persistence logic and testing

- Improved the `persistEnvFile` function to preserve additional fields (uid, dataType, custom metadata) during JSON writes, ensuring no data loss for unrecognized entries.
- Updated the `persistVariableUpdates` function to respect `envVarOverrides` when writing to the global environment file, preventing transient values from being persisted.
- Added regression tests to validate the preservation of additional fields and the correct handling of environment variable overrides in persistence scenarios.

* refactor(tests): streamline fixture writing and enhance variable persistence tests

- Renamed the `writeFileSyncMkdirP` function to `writeFixtureFile` for clarity, emphasizing its role in writing fixture files with parent directory creation.
- Replaced multiple calls to `writeFileSyncMkdirP` with the new `writeFixtureFile` function to improve code readability and maintainability.
- Added tests to verify the deletion of environment variables from disk when removed from the environment map, ensuring accurate persistence behavior.
- Implemented checks to prevent runtime variables from being persisted, safeguarding against unintended data leaks.

* test(env-file): add integration test for JSON environment variable persistence

- Implemented a new test to verify that typed environment variables are correctly persisted to a JSON file using the --env-file option.
- Ensured that existing entries retain their uid and custom fields during the persistence process, validating the shape-preservation guarantee.
- Enhanced the test to check that new typed variables from the script are accurately written and that untouched entries remain unchanged.

* test(env-file): add integration tests for YAML and .bru variable persistence

- Implemented new tests to verify the persistence of typed environment variables to both YAML and .bru files using the --env-file option.
- Ensured that typed values are correctly serialized with appropriate annotations in the output files, validating the correct handling of different formats.
- Enhanced coverage for the persistence behavior of environment variables, confirming that existing entries remain unchanged while new variables are accurately written.

* test(env-file): enhance JSON environment variable persistence tests

- Added a new test to verify the preservation of native typed values in JSON environment files when unrelated keys are touched during script execution.
- Ensured that typed values maintain their original types and are auto-annotated with the correct dataType, validating the integrity of the environment variable persistence process.
- Expanded the typed-value inference tests to cover cross-type transitions, confirming that existing dataType annotations are ignored when new values are written.

* refactor(env-vars): transition env-var overrides to Map for improved persistence handling

- Changed the `envVarOverrides` from a Set to a Map to track injected values, enhancing the logic for distinguishing between transient and deliberate variable writes.
- Updated related functions to accommodate the new Map structure, ensuring that only matching overrides are filtered out during persistence.
- Enhanced documentation and tests to reflect the new behavior, confirming that deliberate script writes with different values are correctly persisted.

* test(integration): refine comments and enhance clarity in typed persistence tests

* test(integration): add tests for variable persistence in tests blocks and error handling

- Implemented new tests to verify that variables set within `tests {}` blocks are correctly persisted to the environment and collection files.
- Added a test to ensure that variables written before an error in a `tests {}` block are still saved, confirming the integrity of partial results during execution.
- Enhanced existing tests with detailed comments for clarity and understanding of the persistence behavior in various scenarios.

* test(integration): add collection variable persistence test for error handling

- Introduced a new test to verify that collection variables set before an error in a `tests {}` block are correctly persisted to the collection file.
- Enhanced existing tests to ensure that both environment and collection variables maintain their expected values during error scenarios, confirming the robustness of variable persistence.

* test(integration): add test for environment and collection variable persistence from post-response expressions

- Introduced a new test to verify that environment and collection variables mutated as a side effect of `vars:post-response` expressions are correctly persisted.
- Enhanced the `runSingleRequest` function to synchronize variable updates after executing post-response scripts, ensuring that changes are reflected in the environment and collection files.
- This change improves the robustness of variable handling in the CLI, aligning it with the expected behavior of the desktop application.

* refactor(persist-variables): streamline JSON parsing and enhance variable persistence tests

- Simplified the JSON parsing logic in the `persistEnvFile` function by removing unnecessary try-catch blocks, ensuring that malformed files are handled gracefully.
- Updated tests to verify the persistence of new collection variable types, including numbers, booleans, and objects, ensuring that typed variables are correctly serialized and maintained.
- Enhanced existing tests to confirm that plain string variables remain unchanged during persistence, improving the robustness of variable handling in the CLI.

* jsdoc change
2026-06-30 20:36:29 +05:30
sharan-bruno
2f0f2e1c79 feat(import): set yml as a default file format across all types of imports (#8363)
* feat(import): set yml as a default file formal accross all type of imports

* addressed review comments

* addressed review comments

* set DEFAULT_COLLECTION_FORMAT as the default for collection format options
2026-06-30 19:07:09 +05:30
prateek-bruno
bb21d4c1c9 fix: strip snapshot key for v4-migration tab (#8354) 2026-06-30 19:04:58 +05:30
Ryan
cfb0db3f90 Merge pull request #8369 from rreyn-bruno/chore/improve-bug-report-template
chore: improve bug report issue template
2026-06-30 07:29:01 -05:00
naman-bruno
c01e4a0ee2 feat(ai): Improve ai context (#8404) 2026-06-30 14:56:04 +05:30
sanish chirayath
49b9af1046 feat(converters): re-enable variable scripting translations (#8388)
* feat(translations): restore commented-out collection and global variable methods

- Reintroduced translations for `setCollectionVar`, `deleteCollectionVar`, `deleteAllCollectionVars`, and their global counterparts in the Postman to Bruno and Bruno to Postman translators.
- Updated tests to validate the translations for these methods, ensuring accurate functionality and consistency across variable management.
- Removed outdated comments regarding UI updates, reflecting the current state of the implementation.

* feat(tests): add translations for bru.getAllGlobalEnvVars and pm.globals.toObject

- Introduced new test cases to validate the translation of `bru.getAllGlobalEnvVars` to `pm.globals.toObject` and vice versa.
- Enhanced the test suite for both Bruno to Postman and Postman to Bruno translations, ensuring comprehensive coverage for variable management methods.
2026-06-30 00:22:56 +05:30
Sid
f6f76423bc ci: use env vars instead of hard coded paths (#8389) 2026-06-29 17:05:31 +05:30
Sid
1c9355e49e feat(electron): add pkg configuration for installation location and relocatability (#8403) 2026-06-29 13:35:11 +05:30
sanish chirayath
87f74262bb feat(variables): persist scripted variable changes by default + re-enable disabled scripting APIs (#8315)
* feat(variables): add variable persistence with scripting

feat(collections): implement script-driven update for collection variables, ensuring direct root modification and draft synchronization

feat(collections): enhance script variable management with baseline tracking and draft preservation

* feat(variables): add runtime variable updates and optimize disk writes by implementing dirty flags

fix(collections): handle errors during environment persistence in script execution

feat(collections): implement baseline clearing for script execution and optimize variable update handling

feat(tests): add default persistence tests for environment variables and update runtime variable handling

refactor(collections): streamline variable update handling and improve draft management by removing redundant comments and optimizing code clarity

test(collection-vars): add verification for draft edits and script variable visibility in collection settings UI

refactor(collection-vars): update header value selection logic for improved clarity and accuracy in draft isolation tests

* feat(global-environments): enhance global environment updates to resolve stale active UIDs and improve persistence logic

- Updated the `updateGlobalEnvironments` reducer to handle stale active UIDs by matching against environment names.
- Improved the logic for setting global environments and active UIDs to ensure consistency after disk reloads.
- Removed outdated tests related to persisted values in favor of more relevant assertions for environment variable handling.

* feat(variables): enhance typed value handling and persistence in global and collection environments

- Added tests to infer data types (number, boolean, object) when setting environment and collection variables.
- Updated the logic to preserve existing data types when variables are not modified by scripts.
- Implemented dirty flags to track changes in typed variables, ensuring accurate persistence across sessions.
- Refactored related tests to verify the correct behavior of typed variables in various scenarios.

* refactor(variables): streamline data type inference and enhance deletion methods

- Removed redundant data type inference logic from global and collection variable updates to simplify the codebase.
- Updated deletion methods in the Bru class to use Object.keys for improved resilience against user-defined properties.
- Added tests to ensure deletion methods function correctly even when properties are shadowed.
- Enhanced clarity in draft merge tests by standardizing keyboard shortcuts for selecting all text.

* fix(tests): correct variable naming and improve environment panel interactions

- Updated test cases to reflect the correct variable name 'wasSaved' instead of 'was-saved'.
- Modified environment panel interaction to remove forced click, enhancing test reliability.
- Added a utility function to close the environment panel in safe mode tests for better readability and maintainability.

* feat(runtime): enhance variable management and cleanup logic

- Introduced a new method to clear script-driven variable baselines for collections, ensuring no stale data leaks into new requests.
- Updated the handling of runtime variables in the Bru class to track changes with a new dirty flag, improving state management.
- Refactored the application of script environment variables to prevent direct mutations, ensuring immutability and cleaner state updates.
- Enhanced the response handling in the script runtime to conditionally include runtime variables based on their dirty state.

* feat(variables): improve request handling and state management for collections and environments

- Enhanced event listeners to clear global environment baselines on both 'testrun-started' and 'request-queued' events, preventing stale data issues.
- Updated global environment and collection variable update events to ignore stale updates from superseded requests, ensuring accurate state management.
- Refactored the Bru class to optimize variable management, including checks for existing keys before updates and deletions, improving performance and reliability.
- Introduced request UID tracking to maintain consistency across variable updates during concurrent requests.

* refactor(collections): update action to clear script variable baselines

- Replaced the dispatch of `_clearScriptGlobalEnvBaseline` with `clearScriptVariableBaselines` to improve clarity and maintainability in the Redux action handling for collections.

* feat(environments): introduce getScriptModifiedKeys utility for improved variable management

- Added a new utility function, `getScriptModifiedKeys`, to identify keys modified by scripts relative to a baseline, enhancing the handling of data types during variable updates.
- Updated the application of script environment variables to prevent overwriting user-defined draft changes during no-op writes.
- Refactored related logic in collections and global environments to utilize the new utility, ensuring accurate state management and improved clarity in the Redux slices.

* refactor(global-environments): simplify active UID resolution logic in updateGlobalEnvironments reducer

- Streamlined the logic for resolving the active global environment UID by consolidating conditions into a more concise format.
- Removed outdated comments to enhance code clarity and maintainability.
- Updated tests to ensure accurate resolution of active UIDs based on incoming environment data.

* refactor(tests): remove outdated comments and streamline environment variable row expectations

- Eliminated comments related to state sync and inference issues to enhance code clarity.
- Adjusted expectations for environment variable row rendering in tests, focusing on relevant assertions.

* feat(tests): add comprehensive tests for secret variable persistence in environments

- Introduced new test cases to validate the preservation of secret variables when updated via scripts in both collection and global environments.
- Implemented tests to ensure that secret values are encrypted before storage and can be correctly decrypted for subsequent requests.
- Added fixtures and environment configurations for testing secret variable behavior in both bru and yml formats.
- Enhanced utility functions for managing environment configurations and interactions within the test suite.

* feat(tests): enhance environment variable tests and add global variable persistence

- Updated MultiLineEditor and SingleLineEditor components to include data-testid for secret reveal toggle buttons, improving testability.
- Introduced new tests for global environment variable persistence, ensuring non-secret variables survive app restarts and are correctly interpolated.
- Added fixtures for workspace and collections to support the new global variable tests, enhancing the overall test coverage for environment management.
- Refactored utility functions to streamline interactions with environment variables in tests.

* refactor(collections): optimize environment and collection saving logic

- Simplified the persistence logic for active environments by directly constructing the environment copy, reducing unnecessary cloning.
- Updated the collection saving process to utilize the fresh collection state, ensuring accurate data is saved without drafts.
- Enhanced error handling during the save operations to improve reliability and maintainability.

* feat(tests): implement collection variable persistence tests

- Added multiple test cases to validate the persistence of collection variables across app restarts, including typed values and multiple variable settings.
- Created new fixtures for collection variables to support the tests, ensuring accurate simulation of variable management scenarios.
- Enhanced the existing collection management logic to ensure that variables are correctly set and deleted as per the test requirements.

* feat(tests): add tests for typed global environment variable persistence

- Introduced a new test suite to validate the persistence of typed global environment variables across app restarts, ensuring correct data types are maintained.
- Created a fixture for the test collection to simulate setting global variables with various data types, including number, boolean, object, and string.
- Enhanced the test logic to verify that the environment file reflects the correct state before and after application restarts.

* fix(tests): update request tab close interaction in variable persistence tests

* fix(tests): improve hover interaction for collection actions in runner tests

- Updated the hover logic for revealing collection actions to handle sidebar re-renders more reliably.
- Replaced one-shot hover with a polling mechanism to ensure visibility of actions, enhancing test stability.

* refactor(environments): streamline environment variable handling and remove ephemeral metadata logic

- Simplified the comparison logic for environment variables by removing unnecessary ephemeral metadata handling.
- Updated the saving process to directly use the environment variables without stripping metadata, enhancing clarity and maintainability.
- Removed outdated comments and unused utility functions related to ephemeral variables, improving code cleanliness.

* fix(ipc): update persistActiveEnvironment to handle requestUid for stale updates

- Modified the persistActiveEnvironment function to accept a requestUid parameter, allowing for better management of stale updates.
- Enhanced the logic to prevent disk writes for superseded requests, improving data integrity during environment persistence.

* refactor(bru): remove unused envName variable in deleteAllEnvVars method

- Eliminated the envName variable from the deleteAllEnvVars method, simplifying the logic for deleting environment variables.
- Cleaned up the method by removing unnecessary checks related to the envName, enhancing code clarity and maintainability.

* fix(bru): prevent deletion of internal __name__ variable in deleteEnvVar method

- Added a check in the deleteEnvVar method to silently ignore attempts to delete the internal __name__ variable, preserving its integrity.
- Updated tests to verify that the __name__ variable remains unchanged when deleteEnvVar is called with this key.
- Enhanced runtime tests to ensure compatibility with QuickJS by confirming that environment variables set with persist options are handled correctly.

* feat(tests): add legacy support test for environment variable persistence

- Introduced a new test suite to validate that the legacy argument for setting environment variables with persistence is still functional in version 4.
- Created a fixture to simulate the legacy syntax, ensuring that the variable is correctly persisted on disk without errors.
- Enhanced integration testing to confirm that the legacy behavior aligns with the current implementation, maintaining backward compatibility.

* test(tests): enhance legacy environment variable persistence tests for safe and developer modes

- Updated the test suite for `bru.setEnvVar` to verify that the legacy persist flag is correctly handled in both safe and developer modes.
- Introduced a helper function to streamline the verification process and ensure consistent behavior across different execution contexts.
- Adjusted the test logic to reset the environment state between mode switches, maintaining test integrity.
- Improved hover interaction in multiple persistent variable tests to ensure reliable visibility of actions during execution.

* fix(EnvironmentVariablesTable): correct change detection logic for environment variables

- Updated the logic for determining changes in environment variables to compare active current and saved values instead of previously used variablesToSave and savedValues.
- This change ensures accurate detection of modifications before saving, improving user feedback when no changes are present.

* test(tests): enhance secret variable persistence tests for environment configurations

- Updated the test suites for `bru.setEnvVar` and `bru.setGlobalEnvVar` to include interactions with the secrets tab, ensuring visibility of secret variables during various states of the environment.
- Added checks to confirm that the eye toggle functionality correctly reveals the values of secret variables after setting and overwriting them.
- Improved test coverage for secret variable persistence, validating that the expected values are displayed in both collection and global environment contexts.
2026-06-26 23:01:37 +05:30
naman-bruno
30b4512983 feat(ai): mode toggle UI and add AI support for folder & collection (#8385) 2026-06-26 18:41:21 +05:30
mohit-bruno
33e8f5ca4a test: workspace import and validation testcase (TC-969) (#8349)
* test cases for workspace import and validation TC-969, jira: https://usebruno.atlassian.net/browse/BRU-3575

* incorporated comments, moved findWorkspaceDirByName function to helpers.ts, fixed all the comments

* modified as per comment provided , removed css locators , used playwright inbuilt methods , handled timeout

* Created file structure as per comment provided, added tc-id , resolved code-rabbit review

* incorporated comments removed commented line, removed timeouts, modified package.json added package in dev dependencies

* changed const l to locators for better readbility

* - Reorganized test helpers: split title-bar locators into title-bar.ts and
  import-workspace flow into workspace/import-workspace.ts for reuse
- Replaced brittle .bruno-modal-card/CSS locators with stable role/testid/label
  based locators
- Added a data-testid for the Import Workspace modal and removed the redundant one
- Cleaned up unnecessary comments
- Updated package-lock.json

* minor changes

* minor changes

* addressed comments

* addressed comments for variable naming

* minor changes
2026-06-26 16:57:02 +05:30
Sid
0a3ee95310 chore: update to latest stable lodash version (#8350) 2026-06-26 16:52:11 +05:30
sanish chirayath
7756564201 fix(workspace-watcher): update IPC channel names for environment file events to match renderer expectations and add regression tests (#8370) 2026-06-26 15:16:49 +05:30
Pooja
d6e17e1dab feat(environments): split variables and secrets into separate tabs (#8191) 2026-06-26 12:21:58 +05:30
Ryan
24bbd8cc36 Fix typos in BugReport.yaml descriptions 2026-06-25 11:04:38 -05:00
rreyn-bruno
b1b43be197 chore: improve bug report issue template
- Split reproduction into a required 'Steps to reproduce' field
- Replace the .bru file prompt with an optional 'Collection to reproduce'
- Disable blank issues to encourage use of the templates
2026-06-25 10:50:05 -05:00
naman-bruno
1ceea0797e feat(ai): add AI chat sidebar with code block and diff view components (#8358) 2026-06-25 18:21:56 +05:30
naman-bruno
d8d2fec166 refactor(migration): extract migration modal (#8359) 2026-06-25 16:53:31 +05:30
naman-bruno
437e0c7dac feat(ai): OpenAI-compatible endpoints support (#8365) 2026-06-25 16:15:22 +05:30
sanish chirayath
7765320aa5 feat(woof): add WOOF easter egg (#8301)
* feat(woof): add WOOF easter egg

* refactor(woof): extract WOOF response logic into a separate utility function

* refactor: rename the response function
2026-06-24 16:46:18 +05:30
ravindra-bruno
42e9c9a309 fix(ai): validate provider keys via /v1/models instead of a generatio… (#8340) 2026-06-24 13:52:44 +05:30
naman-bruno
84c53b65c2 feat(app): introduce standalone app functionality (#8338) 2026-06-24 13:21:05 +05:30
Sid
cbc812b131 chore: change app builder to bruno team (#8218) 2026-06-24 13:11:44 +05:30
Sid
577730609c ci: group the runner to PR (#8343) 2026-06-23 20:47:40 +05:30
sachin-thakur-bruno
7adbc6b14e feat(network-columns-resize)/added resizable columns in network panel (#8265) 2026-06-23 19:17:18 +05:30
adwait-bruno
800ab6fea0 fix(openapi): copy cURL icon now respects the theme variables (#8314)
Co-authored-by: Adwait Aayush <adwaitaayush@Adwaits-MacBook-Air.local>
2026-06-23 18:06:37 +05:30
naman-bruno
3f69977176 feat: integrate Markdown into NotificationDetail (#8335) 2026-06-23 15:24:04 +05:30
naman-bruno
bf0e9bcf23 feat(ai): implement AI autocomplete (#8300) 2026-06-23 13:09:17 +05:30
Sid
27c1970076 feat: add cli offline bundle script (#8192) 2026-06-23 12:08:15 +05:30
DeviSriSaiCharan
5f55a5924d fix: prevent redundant workspace switch toast (#8229)
* fix: prevent redundant workspace switch toast
- Added an early return check in the switchWorkspace Redux action.
- Prevents unnecessary state hydration and toast notifications when a user clicks on the already active workspace.

* fix: return boolean status from switchWorkspace action to handle success and error states correctly

* revert: changes made for the switchWorkspace to check if user switchedto the already active workspace

* refactor: prevent unnecessary workspace switches and success notification logic

* Remove async from handleWorkspaceSwitch function
2026-06-22 23:27:03 +05:30
Sid
222ac80f5e fix(devtools): network tab panel inner scroll (#8329)
* fix: ui overflow for the network tab

* test(e2e): scroller behaviour for network tab
2026-06-22 19:32:29 +05:30
shubh-bruno
46498e8423 fix(tests): flaky bound action e2e tests (#8071) 2026-06-22 18:55:05 +05:30
naman-bruno
d2de2d590e feat(app): add request level app (#8294) 2026-06-22 17:30:32 +05:30
Chirag Chandrashekhar
683d487181 feat: faster collection mount via file cache (Beta) (#8222) 2026-06-22 15:03:21 +05:30
naman-bruno
d1ebf578b2 feat: implement collection migration to YML format (#7669) 2026-06-22 15:01:43 +05:30
YONGJAE LEE (이용재)
21efded2bf Await the websocket visibility assertion so it actually runs (#8317)
The 'websocket requests are visible' test calls
expect(page.locator(...)).toBeVisible() without awaiting it. A Playwright
web-first matcher returns a Promise that polls and retries until the
element matches or the timeout elapses. When the expect is not awaited the
Promise is created and immediately discarded, so the assertion never runs
and the test passes whether or not the request is visible in the sidebar.

This is the sole assertion in that test, so the test gave no real coverage.
The six other expect calls in the same file are already awaited, so this
brings the line in line with the file's own idiom.

Signed-off-by: YONGJAE LEE <dev.yongjaelee@gmail.com>
2026-06-22 12:14:22 +05:30
Utkarsh
fd30eaeccf feat(ai-assist): add docs generation with folder and collection context (#8246) 2026-06-22 11:38:28 +05:30
Sid
ccf62641dd Update CODEOWNERS (#8324)
* Update CODEOWNERS
2026-06-22 11:20:49 +05:30
Bijin A B
099cdaf232 test(preferences): set default preferences values to be overriden by tests with custom preferences (#8321) 2026-06-21 15:07:12 +05:30
Bijin A B
34a61ebe28 fix(playwright): fix flaky tests (#8319) 2026-06-20 19:15:32 +05:30
Bijin A B
a93e1dc8bf test: fix test and update default retry in local to 0 (#8316) 2026-06-20 02:17:14 +05:30
sachin-bruno
4fffef51ba Feature(size/L): BRU-2542 Choose environments to include and show versions in the Generate Documentation modal (#8268)
* BRU-1128 bug fix OpenAPI import error message

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

* Feat:BRU-2542 Added virtualization for env lists

* Feat:BRU-2542 Reverted IPCError modal changes

* BRU-2542 removed wait mount from playwrite script

* BRU-2542 Added select all checkbox

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

* BRU-2542 Comments addressed

---------

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

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

* fix: maintain state for read and cleared notification ids

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

* feat: revamp Notifications

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

* fix: break things into components and use events

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

* fix: icon + more padding

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

* chore: use classnames

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

* chore: remove redundancy

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

* chore: make it pixel accurate

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

* fix: remove redundant useMemo

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

* fix: colors of notification modal

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

* fix: use color paletter + fix badge color

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

* fix: ensure semantics for notification icon

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

* fix: handle keyboard navigation for drawer items

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

* fix: colors, no notification view, etc

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

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

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

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

* fix

* Apply suggestions from code review

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

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

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

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

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

---------

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

* BRU-3560 Comments fixed

* BRU-3560 Comments resolved

* BRU-3560 Comments resolved

* BRU-3560 Comments resolved

---------

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

* Add assert to persistes values

* id name change

* replace hardcoded timeout

* grant type fix

* missing keys in process auth

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

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

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

* chore: surgically update protobufjs

* chore: dedupe axios
2026-06-12 19:01:15 +05:30
sachin-thakur-bruno
db195fe302 feat(dev-tools-rquest-resize): dev tools details panel can be resized horizontally via a drag handle (#8234) 2026-06-12 18:10:02 +05:30
sachin-thakur-bruno
e7e6cdfa51 feat(dev-tools)/adds sorting on columns with verticle borders (#8238) 2026-06-12 17:58:22 +05:30
gopu-bruno
7a24b1924d fix(workspace): keep workspace nav tabs visible when editing docs (#8249) 2026-06-12 17:19:15 +05:30
Bhavik Mehta
13363d7931 fix: show unsaved changes prompt when closing tab with Cmd+W (#8245) 2026-06-12 12:41:25 +05:30
gopu-bruno
1d3a412539 feat(workspace): move external collections into the workspace (#8196) 2026-06-12 11:22:53 +05:30
Pooja
59b4a16b79 fix(timeline): scope scripted requests to their own request (#8210)
* fix(timeline): scope scripted requests to their own request

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

* chore: shorten comment in pane expand reducers

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

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

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

* fix: update key column identification to use isKeyField property

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

* fix: update addMultipartFileToLastRow to target the last row correctly

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

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

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

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

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

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

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

* fix

* fix

* fix

* improve: UX

* improve: new message ui

* fix

* fix

* fix

* fix

* fix

* fix: rename message title

* chore: cleanup

* change: add message color

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

---------

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

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

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

* fix/3112

* Implicit grant type

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

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

Refs: BRU-3409

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

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

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

---------

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

* chore: add test

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

---------

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

* REMOVED console.log

* ADDED test case with fixture to test proxy import

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

* RESOLVED githiub converstation

changed afterAll --> afterEach

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

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

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

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

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

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

* refactor: enhance import issue logging and toast notifications

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

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

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

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

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

* style: update ImportIssuesToast styling for improved user experience

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

* addressed review comments

* addressed review comments

* updated the package-lock file

* addressed review comments

* addressed review comments

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

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

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

Three call sites updated:

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

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

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

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

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

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

Add 12 jest tests covering all 3 affected grant types.

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

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

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

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

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

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

---------

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

* Remove console log from snapshot test

Removed console log statement from folder.spec.ts

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

* test(cli): add interpolation multipart  tests

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

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

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

* chore: codestyle

---------

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

* addressed review comments

* addressed review comments

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

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

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

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

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

* feat: add hasGlobalEnvVar method to bru shim

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

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

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

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

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

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

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

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

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

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

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

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

* docs: updated Readme.md

---------

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

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

* addressed review comments

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

* fix: crash ui

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

* test(e2e): environment persistence across collections

* test: migration test

* Update environment.spec.ts

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

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

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

* refactor: update response translation for body assertions

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

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

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

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

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

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

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

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

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

* feat: enhance response assertion translations for negated variants

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

* refactor: convert status assertion utility to ES module syntax

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

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

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

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

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

* fix: reduce padding for dark mode app errors

---------

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

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

* refactor: update response translation for body assertions

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

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

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

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

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

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

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

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

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

* feat: enhance response assertion translations for negated variants

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

* refactor: convert status assertion utility to ES module syntax

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

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

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

* chore: test text

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

* test: non serial

* chore: fix theme

* fix: ui polish

* chore: import

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

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

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

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

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

---------

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

* fix: add selectors for examples with index

* test: better locator

* fix: duplicate name collision

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

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

* addressed review comments

* addressed review comment

* addressed review comment - added a test id

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

* Potential fix for pull request finding

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

---------

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

2
.gitattributes vendored Normal file
View File

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

2
.github/CODEOWNERS vendored
View File

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

View File

@@ -49,14 +49,21 @@ body:
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of the bug and how it's effecting your work along with steps to reproduce.
description: A clear and concise description of the bug and how it's affecting your work
validations:
required: true
- type: textarea
attributes:
label: .bru file to reproduce the bug
description: Attach your .bru file here that can reproduce the problem.
label: Steps to reproduce
description: The exact steps that can be performed to reproduce the issue
validations:
required: true
- type: textarea
attributes:
label: Collection to reproduce
description: If possible, please attach the collection where the bug is present
validations:
required: false

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Discussions
url: https://github.com/usebruno/bruno/discussions

View File

@@ -11,5 +11,8 @@ runs:
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi

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

@@ -11,5 +11,8 @@ runs:
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb libxml2-utils
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi

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

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

@@ -0,0 +1,91 @@
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: |
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi
- 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

@@ -40,8 +40,11 @@ jobs:
- name: Install npm dependencies
run: |
npm ci --legacy-peer-deps
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi
- name: Install test collection dependencies
run: npm ci --prefix packages/bruno-tests/collection

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

146
.github/workflows/tests-linux.yml vendored Normal file
View File

@@ -0,0 +1,146 @@
name: Linux Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
unit-test:
name: Unit Tests (Linux)
timeout-minutes: 60
runs-on: ubuntu-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 (Linux)
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: Run CLI Tests
uses: ./.github/actions/tests/run-cli-tests
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
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 (Linux)
timeout-minutes: 240
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Install System Dependencies (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 \
gsettings-desktop-schemas dbus-x11
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Configure Chrome Sandbox
run: |
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi
- name: Run E2E Tests
uses: ./.github/actions/tests/run-e2e-tests
with:
os: ubuntu
- name: Upload Playwright Report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
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

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

@@ -0,0 +1,127 @@
name: macOS Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
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: 240
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

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

@@ -0,0 +1,138 @@
name: Windows Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
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: 240
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

View File

@@ -1,82 +0,0 @@
name: Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
jobs:
unit-test:
name: Unit Tests
timeout-minutes: 60
runs-on: ubuntu-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
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: Run CLI Tests
uses: ./.github/actions/tests/run-cli-tests
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
e2e-test:
name: Playwright E2E Tests
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Install System Dependencies (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
run: |
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run playwright Tests
uses: ./.github/actions/tests/run-e2e-tests
with:
os: ubuntu
- name: Upload Playwright Report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

4
.gitignore vendored
View File

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

View File

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

335
package-lock.json generated
View File

@@ -30,7 +30,6 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.1",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -38,10 +37,13 @@
"@storybook/react": "^10.1.10",
"@storybook/react-webpack5": "^10.1.10",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/adm-zip": "^0.5.8",
"@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",
"adm-zip": "^0.5.17",
"concurrently": "^8.2.2",
"cross-env": "10.1.0",
"eslint": "^9.39.4",
@@ -50,7 +52,7 @@
"globals": "^16.1.0",
"husky": "^9.1.7",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"lodash-es": "^4.17.23",
"nano-staged": "^0.8.0",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
@@ -67,6 +69,84 @@
"dev": true,
"license": "MIT"
},
"node_modules/@ai-sdk/anthropic": {
"version": "3.0.15",
"resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.15.tgz",
"integrity": "sha512-FCNy6pABPe5Qb1VPbdLLIi/XkQN2g/fKUcl1GcXxIU3Ofr+vOND8cyZfH20cMODR523FSGfwswJoJic8skr8qg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.4",
"@ai-sdk/provider-utils": "4.0.8"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/gateway": {
"version": "3.0.16",
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.16.tgz",
"integrity": "sha512-OOY5CfRJiHvh/8np2vs1RQaCZ5hWv2qOeEmmeiABXK3gLQHUVnCO+1hhoLsZdHM5iElu6M407dAOfyvTsKJqcQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.4",
"@ai-sdk/provider-utils": "4.0.8",
"@vercel/oidc": "3.1.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/openai": {
"version": "3.0.12",
"resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.12.tgz",
"integrity": "sha512-zqLWEKuaKnjXhu7xCw1jgz/+yTbd3F7EtgU4T2Q8BAo8OJC5wZv14l+kwM7Jai7M1/2Y2T/zBkrfiIu+7NsvfQ==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.4",
"@ai-sdk/provider-utils": "4.0.8"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@ai-sdk/provider": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.4.tgz",
"integrity": "sha512-5KXyBOSEX+l67elrEa+wqo/LSsSTtrPj9Uoh3zMbe/ceQX4ucHI3b9nUEfNkGF3Ry1svv90widAt+aiKdIJasQ==",
"license": "Apache-2.0",
"dependencies": {
"json-schema": "^0.4.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@ai-sdk/provider-utils": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.8.tgz",
"integrity": "sha512-ns9gN7MmpI8vTRandzgz+KK/zNMLzhrriiKECMt4euLtQFSBgNfydtagPOX4j4pS1/3KvHF6RivhT3gNQgBZsg==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/provider": "3.0.4",
"@standard-schema/spec": "^1.1.0",
"eventsource-parser": "^3.0.6"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -6865,6 +6945,16 @@
"dev": true,
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@colors/colors": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz",
"integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -9606,6 +9696,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@parcel/watcher": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz",
@@ -10048,9 +10147,9 @@
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@radix-ui/primitive": {
@@ -11271,6 +11370,12 @@
"node": ">=18.0.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@storybook/addon-webpack5-compiler-babel": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@storybook/addon-webpack5-compiler-babel/-/addon-webpack5-compiler-babel-4.0.0.tgz",
@@ -12290,7 +12395,7 @@
"@swagger-api/apidom-core": "^1.4.0",
"@swagger-api/apidom-error": "^1.4.0",
"@types/ramda": "~0.30.0",
"axios": "1.13.6",
"axios": "1.16.0",
"minimatch": "^7.4.3",
"process": "^0.11.10",
"ramda": "~0.30.0",
@@ -12575,6 +12680,16 @@
"node": ">=10.13.0"
}
},
"node_modules/@types/adm-zip": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -12812,6 +12927,13 @@
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jsdom": {
"version": "20.0.1",
"resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz",
@@ -13401,6 +13523,15 @@
"resolved": "packages/bruno-toml",
"link": true
},
"node_modules/@vercel/oidc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
@@ -14057,9 +14188,9 @@
}
},
"node_modules/adm-zip": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
@@ -14074,6 +14205,24 @@
"node": ">= 14"
}
},
"node_modules/ai": {
"version": "6.0.39",
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.39.tgz",
"integrity": "sha512-hF05gF4H+IxuilA8kNANVVHQXduTJsJaH74jmlmy8mcQt3NZgPYe2zZNyGBV4DPDYTUDt1h31hbLgQqJTn5LGA==",
"license": "Apache-2.0",
"dependencies": {
"@ai-sdk/gateway": "3.0.16",
"@ai-sdk/provider": "3.0.4",
"@ai-sdk/provider-utils": "4.0.8",
"@opentelemetry/api": "1.9.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"zod": "^3.25.76 || ^4.1.8"
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -14601,9 +14750,9 @@
}
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
@@ -14617,7 +14766,7 @@
"integrity": "sha512-CS6WE8chZpEDKxv4IFwr5zcG7InMC6Ek0aj2n2tHauBh+8KiYVC4qMn3N2arjR5tnyILQuTGlI0mc83hgWxS4Q==",
"license": "MIT",
"dependencies": {
"axios": "1.13.6",
"axios": "1.16.0",
"des.js": "^1.1.0",
"dev-null": "^0.1.1",
"js-md4": "^0.3.2"
@@ -16105,6 +16254,21 @@
"node": ">=0.2.5"
}
},
"node_modules/cli-table3": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz",
"integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==",
"license": "MIT",
"dependencies": {
"string-width": "^4.2.0"
},
"engines": {
"node": "10.* || >= 12.*"
},
"optionalDependencies": {
"@colors/colors": "1.5.0"
}
},
"node_modules/cli-truncate": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
@@ -16469,7 +16633,7 @@
"date-fns": "^2.30.0",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"shell-quote": "^1.8.4",
"spawn-command": "0.0.2",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
@@ -19009,6 +19173,15 @@
"bare-events": "^2.7.0"
}
},
"node_modules/eventsource-parser": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz",
"integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/evp_bytestokey": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
@@ -23397,6 +23570,12 @@
"node": "*"
}
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -23700,15 +23879,15 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.0.tgz",
"integrity": "sha512-koAgswPPA+UTaPN64Etp+PGP+WT6oqOS2NMi5yDkMaiGw9qY4VxQbQF0mtKMyr4BlTznWyzePV5UpECTJQmSUA==",
"license": "MIT"
},
"node_modules/lodash.camelcase": {
@@ -26584,7 +26763,7 @@
"integrity": "sha512-l+fsjYEkTik3m/G0pE7gMr4qBJP84LhK779oQm6MBzhBGpd4By4qieTW+4FUAlNCyzQTynn3Nhsa50c0IELSxQ==",
"license": "MIT",
"dependencies": {
"axios": "1.13.6",
"axios": "1.16.0",
"rusha": "^0.8.14"
},
"engines": {
@@ -26841,9 +27020,9 @@
}
},
"node_modules/protobufjs": {
"version": "7.4.0",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz",
"integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
"integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -29420,10 +29599,9 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
"dev": true,
"version": "1.8.4",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz",
"integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -32439,6 +32617,12 @@
"node": ">=0.10.0"
}
},
"node_modules/workerpool": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.2.tgz",
"integrity": "sha512-8PCeZlCwu0+8hXruze1ahYNsY+M0LOCmbmySZ9BWWqWIXP9TAXa6FZCxACTDL/0j47pFcC4xW98Gr8nAC5oymg==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -32742,6 +32926,15 @@
"node": ">= 14"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"packages/bruno-app": {
"name": "@usebruno/app",
"version": "2.0.0",
@@ -32762,6 +32955,7 @@
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",
"cookie": "0.7.1",
"diff": "^5.2.0",
"diff2html": "^3.4.47",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
@@ -32789,7 +32983,7 @@
"jsonschema": "^1.5.0",
"know-your-http-well": "^0.5.0",
"linkify-it": "^5.0.0",
"lodash": "^4.17.21",
"lodash": "4.18.1",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mime-types": "^3.0.2",
@@ -32821,7 +33015,7 @@
"react-virtuoso": "^4.18.1",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"shell-quote": "^1.8.4",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "^5.31.0",
@@ -34312,18 +34506,6 @@
"node": ">=10"
}
},
"packages/bruno-app/node_modules/shell-quote": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"packages/bruno-app/node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
@@ -34374,10 +34556,11 @@
"@usebruno/lang": "0.12.0",
"@usebruno/requests": "^0.1.0",
"aws4-axios": "^3.3.15",
"axios": "1.13.6",
"axios": "1.16.0",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chalk": "^3.0.0",
"cli-table3": "^0.6.5",
"decomment": "^0.9.5",
"form-data": "4.0.4",
"fs-extra": "^10.1.0",
@@ -34385,7 +34568,7 @@
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.1",
"lodash": "^4.17.21",
"lodash": "4.18.1",
"qs": "^6.14.1",
"socks-proxy-agent": "^8.0.2",
"xmlbuilder": "^15.1.1",
@@ -34918,16 +35101,18 @@
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@usebruno/common": "^0.1.0",
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.1",
"jscodeshift": "^17.3.0",
"lodash": "^4.17.21",
"lodash": "4.18.1",
"nanoid": "3.3.8",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.25.0",
"@opencollection/types": "0.9.1",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
@@ -35011,6 +35196,8 @@
"name": "bruno",
"version": "2.0.0",
"dependencies": {
"@ai-sdk/anthropic": "3.0.15",
"@ai-sdk/openai": "3.0.12",
"@aws-sdk/credential-providers": "3.1019.0",
"@grpc/grpc-js": "^1.13.2",
"@grpc/proto-loader": "^0.7.13",
@@ -35025,9 +35212,10 @@
"@usebruno/schema": "0.7.0",
"about-window": "^1.15.2",
"adm-zip": "^0.5.16",
"ai": "6.0.39",
"archiver": "^7.0.1",
"aws4-axios": "^3.3.15",
"axios": "1.13.6",
"axios": "1.16.0",
"axios-ntlm": "^1.4.2",
"chai": "^4.3.7",
"chokidar": "^3.5.3",
@@ -35049,7 +35237,7 @@
"iconv-lite": "^0.6.3",
"is-valid-path": "^0.1.1",
"js-yaml": "^4.1.1",
"lodash": "^4.17.21",
"lodash": "4.18.1",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"qs": "^6.14.1",
@@ -35057,7 +35245,9 @@
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^10.0.0",
"yup": "^0.32.11"
"workerpool": "10.0.2",
"yup": "^0.32.11",
"zod": "^4.1.8"
},
"devDependencies": {
"electron": "~37.6.1",
@@ -35595,14 +35785,16 @@
"license": "MIT",
"dependencies": {
"@types/nanoid": "^2.1.0",
"@usebruno/common": "0.1.0",
"@usebruno/lang": "0.12.0",
"ajv": "^8.17.1",
"lodash": "^4.17.21",
"lodash": "4.18.1",
"yaml": "^2.3.4"
},
"devDependencies": {
"@babel/preset-env": "^7.22.0",
"@babel/preset-typescript": "^7.22.0",
"@opencollection/types": "0.9.1",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -35802,7 +35994,7 @@
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"atob": "^2.1.2",
"axios": "1.13.6",
"axios": "1.16.0",
"btoa": "^1.2.1",
"chai": "^4.3.7",
"chai-string": "^1.5.0",
@@ -35810,7 +36002,7 @@
"crypto-js": "^4.2.0",
"json-query": "^2.2.2",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.17.21",
"lodash": "4.18.1",
"moment": "^2.29.4",
"nanoid": "3.3.8",
"node-fetch": "^2.7.0",
@@ -35978,12 +36170,32 @@
"version": "0.12.0",
"license": "MIT",
"dependencies": {
"@usebruno/common": "0.1.0",
"arcsecond": "^5.0.0",
"dotenv": "^16.3.1",
"lodash": "^4.17.21",
"lodash": "4.18.1",
"ohm-js": "^16.6.0"
}
},
"packages/bruno-pool": {
"name": "@usebruno/pool",
"version": "0.1.0",
"extraneous": true,
"license": "MIT",
"dependencies": {
"@usebruno/filestore": "0.1.0",
"workerpool": "10.0.2"
},
"devDependencies": {
"@rollup/plugin-commonjs": "23.0.2",
"@rollup/plugin-node-resolve": "15.0.1",
"@rollup/plugin-typescript": "12.1.2",
"@types/node": "^24.1.0",
"rollup": "3.30.0",
"tslib": "2.8.1",
"typescript": "5.4.5"
}
},
"packages/bruno-query": {
"name": "@usebruno/query",
"version": "0.1.0",
@@ -36008,7 +36220,7 @@
"@grpc/grpc-js": "^1.13.3",
"@grpc/proto-loader": "^0.7.15",
"@types/qs": "^6.9.18",
"axios": "1.13.6",
"axios": "1.16.0",
"debug": "^4.4.3",
"google-protobuf": "^4.0.0",
"grpc-js-reflection-client": "^1.3.0",
@@ -36185,6 +36397,7 @@
"version": "0.7.0",
"license": "MIT",
"dependencies": {
"@usebruno/common": "0.1.0",
"nanoid": "3.3.8",
"yup": "^0.32.11"
}
@@ -36211,12 +36424,22 @@
"node": ">=14.17"
}
},
"packages/bruno-storage": {
"name": "@usebruno/storage",
"version": "0.1.0",
"extraneous": true,
"license": "MIT",
"devDependencies": {
"@types/node": "^24.1.0",
"typescript": "5.4.5"
}
},
"packages/bruno-tests": {
"name": "@usebruno/tests",
"version": "0.0.1",
"license": "MIT",
"dependencies": {
"axios": "1.13.6",
"axios": "1.16.0",
"body-parser": "2.2.0",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
@@ -36228,7 +36451,7 @@
"http-proxy": "^1.18.1",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.17.21",
"lodash": "4.18.1",
"multer": "^1.4.5-lts.1",
"ws": "^8.18.3"
}
@@ -36480,7 +36703,7 @@
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
"lodash": "^4.17.21"
"lodash": "4.18.1"
}
}
}

View File

@@ -23,7 +23,6 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "0.9.1",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -31,10 +30,13 @@
"@storybook/react": "^10.1.10",
"@storybook/react-webpack5": "^10.1.10",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/adm-zip": "^0.5.8",
"@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",
"adm-zip": "^0.5.17",
"concurrently": "^8.2.2",
"cross-env": "10.1.0",
"eslint": "^9.39.4",
@@ -43,7 +45,7 @@
"globals": "^16.1.0",
"husky": "^9.1.7",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"lodash-es": "^4.17.23",
"nano-staged": "^0.8.0",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
@@ -80,9 +82,11 @@
"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:e2e:sanity": "playwright test --project=default --project=system-pac --grep @sanity",
"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 +97,9 @@
]
},
"overrides": {
"axios":"1.13.6",
"axios": "1.16.0",
"rollup": "3.30.0",
"pbkdf2":"3.1.5",
"pbkdf2": "3.1.5",
"electron-store": {
"conf": {
"json-schema-typed": "8.0.1"

View File

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

View File

@@ -27,6 +27,7 @@
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",
"cookie": "0.7.1",
"diff": "^5.2.0",
"diff2html": "^3.4.47",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
@@ -54,7 +55,7 @@
"jsonschema": "^1.5.0",
"know-your-http-well": "^0.5.0",
"linkify-it": "^5.0.0",
"lodash": "^4.17.21",
"lodash": "4.18.1",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mime-types": "^3.0.2",
@@ -86,7 +87,7 @@
"react-virtuoso": "^4.18.1",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"shell-quote": "^1.8.4",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "^5.31.0",

View File

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

View File

@@ -0,0 +1,317 @@
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;
}
}
`;
// Tippy renders the popup into document.body, outside StyledWrapper's subtree.
export const PopupWrapper = styled.div`
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-stop {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
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};
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
border-color: ${(props) => props.theme.colors.text.danger};
color: ${(props) => props.theme.colors.text.danger};
}
}
.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,302 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import get from 'lodash/get';
import Tippy from '@tippyjs/react';
import { IconStars, IconX, IconArrowBackUp, IconPlayerStop } from '@tabler/icons';
import { aiGenerateScript, stopAiGeneration } from 'utils/ai';
import StyledWrapper, { PopupWrapper } 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' }
],
'docs': [
{ label: 'Overview', prompt: 'Write an overview section describing the purpose and key features' },
{ label: 'Request', prompt: 'Document the request method, URL, headers, parameters, and body' },
{ label: 'Examples', prompt: 'Add request and response examples with sample JSON' },
{ label: 'Errors', prompt: 'Document common error responses and status codes' }
],
'app-request': [
{ label: 'Send button', prompt: 'Add a button that calls ctx.sendRequest() and displays the response status, headers, and pretty-printed body' },
{ label: 'Form for body', prompt: 'Build a form whose fields override the request body, then send it with ctx.sendRequest({ variables }) and show the result' },
{ label: 'Response viewer', prompt: 'Render ctx.response with collapsible JSON and a banner showing status and response time; update on ctx.onResponseUpdate' },
{ label: 'Test results', prompt: 'List ctx.testResults and ctx.assertionResults with pass/fail badges; refresh on ctx.onResultsUpdate' }
],
'app-collection': [
{ label: 'Request list', prompt: 'List all requests from ctx.listRequests() with their method and url, and a Run button next to each that calls ctx.runRequest(pathname)' },
{ label: 'Dashboard', prompt: 'Build a small dashboard that runs every request from ctx.listRequests() on load and shows status code, response time, and a pass/fail dot for each' },
{ label: 'Form runner', prompt: 'Render a form, and on submit call ctx.runRequest(pathname, { variables }) for a chosen request and display the response' },
{ label: 'Variables panel', prompt: 'Show ctx.variables in a table and allow editing values via ctx.setRuntimeVariable(key, value); react to ctx.onVariablesUpdate' }
]
};
const TITLES = {
'tests': 'Generate Tests',
'pre-request': 'Generate Pre-Request Script',
'post-response': 'Generate Post-Response Script',
'docs': 'Generate Documentation',
'app-request': 'Generate App',
'app-collection': 'Generate App'
};
const PREVIEW_LABELS = {
'docs': 'Preview · replaces current documentation',
'app-request': 'Preview · replaces current app',
'app-collection': 'Preview · replaces current app'
};
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, variables, 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 streamIdRef = useRef(null);
const tippyRef = useRef(null);
// Focus the prompt textarea when coming back from preview
useEffect(() => {
if (isOpen && generated == null) {
tippyRef.current?.popper?.querySelector('.popup-input')?.focus();
}
}, [isOpen, generated]);
// handle Escape key to close the popup
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
tippyRef.current?.hide();
}
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [isOpen]);
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 previewLabel = PREVIEW_LABELS[scriptType] || 'Preview · replaces current script';
const close = useCallback(() => {
tippyRef.current?.hide();
}, []);
const handleGenerate = useCallback(
async (overridePrompt) => {
const text = (overridePrompt ?? prompt).trim();
if (!text || isLoading) return;
setIsLoading(true);
setError(null);
const streamId = `sparkle-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
streamIdRef.current = streamId;
try {
const result = await aiGenerateScript({
scriptType,
prompt: text,
currentScript: currentScript || '',
requestContext,
docsContext,
variables,
streamId
});
if (result?.stopped) {
return;
}
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 {
streamIdRef.current = null;
setIsLoading(false);
}
},
[prompt, isLoading, scriptType, currentScript, requestContext, docsContext, variables]
);
const handleStop = useCallback(() => {
if (streamIdRef.current) {
stopAiGeneration(streamIdRef.current);
}
}, []);
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>
<Tippy
interactive
trigger="click"
placement="bottom-end"
arrow={false}
animation={false}
maxWidth="none"
appendTo={() => document.body}
onCreate={(instance) => (tippyRef.current = instance)}
onShow={(instance) => {
setIsOpen(true);
// rAF so the popup content is in the DOM
requestAnimationFrame(() => instance.popper?.querySelector('.popup-input')?.focus());
}}
onHide={() => {
setIsOpen(false);
setError(null);
}}
render={(attrs) => (
<PopupWrapper className="ai-assist-popup" role="dialog" aria-label={title} tabIndex={-1} {...attrs}>
<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
className="popup-input"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
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 · Shift+Enter for newline</span>
)}
{isLoading ? (
<button
className="btn-stop"
type="button"
onClick={handleStop}
title="Stop generating"
>
<IconPlayerStop size={12} /> Stop
</button>
) : (
<button
className="btn-generate"
type="button"
onClick={() => handleGenerate()}
disabled={!prompt.trim()}
>
Generate
</button>
)}
</div>
</>
) : (
<>
<div className="popup-body">
<div className="preview-section">
<span className="preview-label">{previewLabel}</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>
</>
)}
</PopupWrapper>
)}
>
<button
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
title={title}
type="button"
aria-label={title}
>
<IconStars size={14} strokeWidth={1.75} />
</button>
</Tippy>
</StyledWrapper>
);
};
export default AIAssist;

View File

@@ -0,0 +1,404 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { ThemeProvider } from 'styled-components';
import { aiGenerateScript, stopAiGeneration } from 'utils/ai';
import AIAssist from './index';
jest.mock('utils/ai', () => ({
aiGenerateScript: jest.fn(),
stopAiGeneration: jest.fn()
}));
const theme = {
bg: '#1e1e1e',
text: '#ffffff',
border: { radius: { sm: '4px', md: '6px' } },
colors: {
accent: '#6366f1',
text: { muted: '#9ca3af', danger: '#ef4444' },
bg: { danger: '#ef4444' }
},
input: {
border: '#374151',
bg: '#111827',
focusBorder: '#6366f1'
},
font: { monospace: 'monospace' }
};
const createStore = (aiEnabled = true) => configureStore({
reducer: {
app: (state = { preferences: { ai: { enabled: aiEnabled } } }) => state
}
});
const defaultProps = {
scriptType: 'tests',
currentScript: 'test("ok", () => {});',
onApply: jest.fn()
};
const renderAIAssist = ({
props = {},
aiEnabled = true
} = {}) => {
const mergedProps = { ...defaultProps, ...props };
return render(
<Provider store={createStore(aiEnabled)}>
<ThemeProvider theme={theme}>
<AIAssist {...mergedProps} />
</ThemeProvider>
</Provider>
);
};
const openPopup = () => {
fireEvent.click(screen.getByRole('button', { name: 'Generate Tests' }));
};
describe('AIAssist', () => {
beforeEach(() => {
jest.clearAllMocks();
aiGenerateScript.mockResolvedValue({ content: 'test("generated", () => {});' });
});
describe('visibility', () => {
it('renders nothing when AI is disabled', () => {
const { container } = renderAIAssist({ aiEnabled: false });
expect(container.firstChild).toBeNull();
});
it('renders nothing for an unsupported script type', () => {
const { container } = renderAIAssist({ props: { scriptType: 'unknown-type' } });
expect(container.firstChild).toBeNull();
});
it('renders the trigger when AI is enabled and the script type is supported', () => {
renderAIAssist();
expect(screen.getByRole('button', { name: 'Generate Tests' })).toBeInTheDocument();
});
});
describe('titles', () => {
it.each([
['tests', 'Generate Tests'],
['pre-request', 'Generate Pre-Request Script'],
['post-response', 'Generate Post-Response Script'],
['docs', 'Generate Documentation']
])('uses the correct title for %s', (scriptType, title) => {
renderAIAssist({ props: { scriptType } });
expect(screen.getByRole('button', { name: title })).toBeInTheDocument();
});
});
describe('popup interactions', () => {
it('opens and closes the popup from the trigger and close button', () => {
renderAIAssist();
openPopup();
expect(screen.getByRole('dialog', { name: 'Generate Tests' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
});
it('renders the popup into document.body as a portal', () => {
renderAIAssist();
openPopup();
const dialog = screen.getByRole('dialog', { name: 'Generate Tests' });
const tippyRoot = dialog.closest('[data-tippy-root]');
expect(tippyRoot).not.toBeNull();
expect(tippyRoot.parentElement).toBe(document.body);
});
it('closes the popup when Escape is pressed', () => {
renderAIAssist();
openPopup();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
});
it('closes the popup when clicking outside', () => {
renderAIAssist();
openPopup();
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
});
});
describe('prompt view', () => {
it('shows suggestion chips when the prompt is empty', () => {
renderAIAssist();
openPopup();
expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'JSON body' })).toBeInTheDocument();
});
it('shows docs suggestions for the docs script type', () => {
renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Overview' } });
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Request' })).toBeInTheDocument();
});
it('hides suggestions once the user starts typing', () => {
renderAIAssist();
openPopup();
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
target: { value: 'Add a status test' }
});
expect(screen.queryByRole('button', { name: 'Status 200' })).not.toBeInTheDocument();
});
it('keeps Generate disabled until the prompt has text', () => {
renderAIAssist();
openPopup();
expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
target: { value: 'Add a status test' }
});
expect(screen.getByRole('button', { name: 'Generate' })).toBeEnabled();
});
});
describe('generation flow', () => {
it('generates from a suggestion chip', async () => {
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
scriptType: 'tests',
prompt: 'Add a test asserting the response status code is 200',
currentScript: 'test("ok", () => {});',
requestContext: undefined,
streamId: expect.any(String)
}));
});
expect(screen.getByText('test("generated", () => {});')).toBeInTheDocument();
});
it('passes docs context for folder and collection documentation', async () => {
const docsContext = {
scope: 'folder',
name: 'Users',
collectionName: 'Pet Store API',
folders: [{ name: 'Admin', requestCount: 1, subfolderCount: 0 }],
requests: [{ name: 'List Users', method: 'GET', url: '{{base}}/users' }]
};
renderAIAssist({ props: { scriptType: 'docs', currentScript: '', docsContext } });
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
fireEvent.click(screen.getByRole('button', { name: 'Overview' }));
await waitFor(() => {
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
scriptType: 'docs',
prompt: 'Write an overview section describing the purpose and key features',
currentScript: '',
requestContext: undefined,
docsContext
}));
});
});
it('generates from the prompt input and passes request context', async () => {
const requestContext = {
method: 'GET',
url: 'https://api.example.com/users',
headers: [],
params: [],
body: null
};
renderAIAssist({ props: { requestContext } });
openPopup();
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
target: { value: 'Add auth header test' }
});
fireEvent.click(screen.getByRole('button', { name: 'Generate' }));
await waitFor(() => {
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
scriptType: 'tests',
prompt: 'Add auth header test',
currentScript: 'test("ok", () => {});',
requestContext
}));
});
});
it('generates when pressing Enter', async () => {
renderAIAssist();
openPopup();
const textarea = screen.getByPlaceholderText('Describe what you want to generate...');
fireEvent.change(textarea, { target: { value: 'Add response time test' } });
fireEvent.keyDown(textarea, { key: 'Enter' });
await waitFor(() => {
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
scriptType: 'tests',
prompt: 'Add response time test',
currentScript: 'test("ok", () => {});',
requestContext: undefined
}));
});
});
it('does not generate when pressing Shift+Enter (allows newline)', () => {
renderAIAssist();
openPopup();
const textarea = screen.getByPlaceholderText('Describe what you want to generate...');
fireEvent.change(textarea, { target: { value: 'Add response time test' } });
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true });
expect(aiGenerateScript).not.toHaveBeenCalled();
});
it('shows a loading state while generation is in progress', async () => {
let resolveGenerate;
aiGenerateScript.mockImplementation(() => new Promise((resolve) => {
resolveGenerate = resolve;
}));
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
expect(screen.getByText('Generating...')).toBeInTheDocument();
resolveGenerate({ content: 'test("done", () => {});' });
await waitFor(() => {
expect(screen.getByText('test("done", () => {});')).toBeInTheDocument();
});
});
it('shows a Stop button during generation and cancels via streamId', async () => {
let resolveGenerate;
aiGenerateScript.mockImplementation(() => new Promise((resolve) => {
resolveGenerate = resolve;
}));
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
const stopButton = await screen.findByRole('button', { name: /stop/i });
expect(stopButton).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Generate' })).not.toBeInTheDocument();
const passedStreamId = aiGenerateScript.mock.calls[0][0].streamId;
expect(passedStreamId).toEqual(expect.any(String));
fireEvent.click(stopButton);
expect(stopAiGeneration).toHaveBeenCalledWith(passedStreamId);
resolveGenerate({ stopped: true });
await waitFor(() => {
expect(screen.queryByRole('button', { name: /stop/i })).not.toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Generate' })).toBeInTheDocument();
});
it('shows an API error without entering preview mode', async () => {
aiGenerateScript.mockResolvedValue({ error: 'Provider unavailable' });
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(screen.getByText('Provider unavailable')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
});
it('shows a fallback error when no content is returned', async () => {
aiGenerateScript.mockResolvedValue({});
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(screen.getByText('No content was generated. Try rephrasing your prompt.')).toBeInTheDocument();
});
});
});
describe('preview and apply', () => {
const showPreview = async () => {
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
});
};
it('uses the script preview label for script types', async () => {
await showPreview();
expect(screen.getByText('Preview · replaces current script')).toBeInTheDocument();
});
it('uses the documentation preview label for docs', async () => {
aiGenerateScript.mockResolvedValue({ content: '# API Docs' });
renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Existing' } });
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
fireEvent.click(screen.getByRole('button', { name: 'Overview' }));
await waitFor(() => {
expect(screen.getByText('Preview · replaces current documentation')).toBeInTheDocument();
});
expect(screen.getByText('# API Docs')).toBeInTheDocument();
});
it('applies generated content and closes the popup', async () => {
const onApply = jest.fn();
renderAIAssist({ props: { onApply } });
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply).toHaveBeenCalledWith('test("generated", () => {});');
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
});
it('returns to the prompt view when Back is clicked', async () => {
await showPreview();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(screen.getByPlaceholderText('Describe what you want to generate...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,46 @@
import { useState, useRef, useEffect } from 'react';
import { IconCopy, IconCheck } from '@tabler/icons';
const AssistantCodeBlock = ({ content, language, isOpen, isStreaming, isLast }) => {
const [isCopied, setIsCopied] = useState(false);
const preRef = useRef(null);
useEffect(() => {
if (isStreaming && isOpen && preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [content, isStreaming, isOpen]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1500);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className="assistant-code-block">
<div className="assistant-code-block__header">
<div className="assistant-code-block__meta">
<span className="assistant-code-block__lang">{language || 'code'}</span>
{isOpen && <span className="assistant-code-block__spinner" />}
</div>
<button className="assistant-code-block__btn" onClick={handleCopy} title="Copy">
{isCopied ? <IconCheck size={12} /> : <IconCopy size={12} />}
{isCopied ? 'Copied' : 'Copy'}
</button>
</div>
<pre ref={preRef} className="assistant-code-block__body">
<code className={`language-${language || 'text'}`}>
{content}
{isStreaming && isLast && <span className="cursor">|</span>}
</code>
</pre>
</div>
);
};
export default AssistantCodeBlock;

View File

@@ -0,0 +1,298 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-top: 8px;
border-radius: ${(props) => props.theme.border.radius.base};
overflow: hidden;
border: 1px solid ${(props) => props.theme.border.border1};
background: ${(props) => props.theme.codemirror.bg};
&.accepted {
border-color: ${(props) => props.theme.colors.text.green};
}
&.rejected {
opacity: 0.5;
}
.diff-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: ${(props) => props.theme.background.mantle};
border-bottom: 1px solid ${(props) => props.theme.border.border1};
gap: 8px;
flex-wrap: nowrap;
}
.diff-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
flex-shrink: 0;
.diff-icon {
color: ${(props) => props.theme.brand};
display: flex;
align-items: center;
}
}
.diff-content-type {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 6px;
border-radius: 3px;
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.colors.text.muted};
}
.diff-stats {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
.stat {
padding: 1px 5px;
border-radius: 4px;
}
.additions {
background: ${(props) => props.theme.status.success.background};
color: ${(props) => props.theme.colors.text.green};
}
.deletions {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
.diff-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.diff-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
border: 1px solid transparent;
border-radius: ${(props) => props.theme.border.radius.base};
cursor: pointer;
white-space: nowrap;
&.accept {
background: ${(props) => props.theme.brand};
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&.reject {
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
border-color: ${(props) => props.theme.border.border1};
&:hover {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
border-color: ${(props) => props.theme.status.danger.background};
}
}
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 11px;
border-radius: ${(props) => props.theme.border.radius.base};
font-weight: 500;
&.accepted {
background: ${(props) => props.theme.status.success.background};
color: ${(props) => props.theme.colors.text.green};
}
&.rejected {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
.diff-warning {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 11px;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
&.warn {
background: ${(props) => props.theme.status.warning.background};
color: ${(props) => props.theme.status.warning.text};
}
&.error {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
.diff-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 8px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
border-top: 1px solid ${(props) => props.theme.border.border1};
cursor: pointer;
width: 100%;
&:hover {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
}
.diff-content {
max-height: 300px;
overflow: auto;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 11px;
line-height: 1.5;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.border.border1};
border-radius: 2px;
}
}
.diff-line {
padding: 0 8px 0 4px;
white-space: pre;
display: flex;
min-height: 18px;
line-height: 18px;
.line-number {
width: 24px;
text-align: right;
padding-right: 8px;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
flex-shrink: 0;
opacity: 0.5;
}
.line-prefix {
width: 12px;
flex-shrink: 0;
}
.line-content {
flex: 1;
overflow-x: auto;
}
&.added {
background: ${(props) => props.theme.status.success.background};
.line-content { color: ${(props) => props.theme.colors.text.green}; }
.line-prefix { color: ${(props) => props.theme.colors.text.green}; font-weight: 600; }
}
&.removed {
background: ${(props) => props.theme.status.danger.background};
.line-content { color: ${(props) => props.theme.colors.text.danger}; }
.line-prefix { color: ${(props) => props.theme.colors.text.danger}; font-weight: 600; }
}
&.unchanged {
.line-content { color: ${(props) => props.theme.colors.text.muted}; }
.line-prefix { opacity: 0; }
}
}
.expand-marker {
display: flex;
align-items: center;
padding: 0 8px 0 4px;
min-height: 22px;
background: ${(props) => props.theme.background.mantle};
.expand-gutter {
width: 24px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
}
.expand-buttons {
display: flex;
flex-direction: column;
gap: 0;
}
.expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 11px;
padding: 0;
background: transparent;
border: none;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
opacity: 0.6;
&:hover {
color: ${(props) => props.theme.text};
opacity: 1;
}
}
.expand-line {
flex: 1;
height: 1px;
background: ${(props) => props.theme.border.border1};
margin-left: 8px;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,210 @@
import React, { useMemo, useState } from 'react';
import { diffLines } from 'diff';
import { IconCheck, IconX, IconCode, IconChevronDown, IconChevronUp } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const CONTEXT_LINES = 2;
const EXPAND_CHUNK_SIZE = 20;
const DiffView = ({ originalCode, newCode, onAccept, onReject, status, contentTypeLabel, warning, disableAccept }) => {
const [isExpanded, setIsExpanded] = useState(true);
const [expandedFromTop, setExpandedFromTop] = useState({});
const [expandedFromBottom, setExpandedFromBottom] = useState({});
const diffResult = useMemo(() => {
const changes = diffLines(originalCode || '', newCode || '');
let additions = 0;
let deletions = 0;
let lineNumber = 1;
const lines = changes.flatMap((part) => {
const partLines = part.value.split('\n');
if (partLines[partLines.length - 1] === '') partLines.pop();
return partLines.map((line) => {
const entry = { content: line, lineNumber: null };
if (part.added) {
additions += 1;
entry.type = 'added';
entry.lineNumber = lineNumber++;
} else if (part.removed) {
deletions += 1;
entry.type = 'removed';
} else {
entry.type = 'unchanged';
entry.lineNumber = lineNumber++;
}
return entry;
});
});
return { lines, additions, deletions };
}, [originalCode, newCode]);
const hunks = useMemo(() => {
const { lines } = diffResult;
if (lines.length === 0) return [];
const changedIndices = new Set();
lines.forEach((line, idx) => {
if (line.type === 'added' || line.type === 'removed') changedIndices.add(idx);
});
const visibleIndices = new Set();
changedIndices.forEach((idx) => {
for (let i = Math.max(0, idx - CONTEXT_LINES); i <= Math.min(lines.length - 1, idx + CONTEXT_LINES); i++) {
visibleIndices.add(i);
}
});
const result = [];
let i = 0;
while (i < lines.length) {
if (visibleIndices.has(i)) {
result.push({ type: 'line', data: lines[i], index: i });
i += 1;
} else {
const start = i;
while (i < lines.length && !visibleIndices.has(i)) i += 1;
result.push({
type: 'collapsed',
startIndex: start,
count: i - start,
lines: lines.slice(start, i)
});
}
}
return result;
}, [diffResult]);
const expandUp = (startIndex, totalLines) => {
setExpandedFromTop((prev) => {
const current = prev[startIndex] || 0;
const bottomExpanded = expandedFromBottom[startIndex] || 0;
const remaining = totalLines - current - bottomExpanded;
return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) };
});
};
const expandDown = (startIndex, totalLines) => {
setExpandedFromBottom((prev) => {
const current = prev[startIndex] || 0;
const topExpanded = expandedFromTop[startIndex] || 0;
const remaining = totalLines - topExpanded - current;
return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) };
});
};
if (diffResult.additions === 0 && diffResult.deletions === 0) return null;
const renderActions = () => {
if (status === 'accepted') {
return (
<span className="status-badge accepted">
<IconCheck size={12} /> Applied
</span>
);
}
if (status === 'rejected') {
return (
<span className="status-badge rejected">
<IconX size={12} /> Dismissed
</span>
);
}
return (
<div className="diff-actions">
<button className="diff-btn reject" onClick={onReject} title="Dismiss changes">
<IconX size={12} />
</button>
<button className="diff-btn accept" onClick={onAccept} title="Apply changes" disabled={disableAccept}>
<IconCheck size={12} /> Apply
</button>
</div>
);
};
const renderLine = (line, key) => (
<div key={key} className={`diff-line ${line.type}`}>
<span className="line-number">{line.type !== 'removed' ? line.lineNumber : ''}</span>
<span className="line-prefix">{line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '}</span>
<span className="line-content">{line.content || ' '}</span>
</div>
);
const renderHunks = () =>
hunks.map((hunk, idx) => {
if (hunk.type === 'line') return renderLine(hunk.data, `line-${hunk.index}`);
const topCount = expandedFromTop[hunk.startIndex] || 0;
const bottomCount = expandedFromBottom[hunk.startIndex] || 0;
const remainingCount = hunk.count - topCount - bottomCount;
const topLines = hunk.lines.slice(0, topCount);
const bottomLines = hunk.lines.slice(hunk.count - bottomCount);
const isAtTop = idx === 0;
const isAtBottom = idx === hunks.length - 1;
return (
<React.Fragment key={`collapsed-${hunk.startIndex}`}>
{topLines.map((line, lineIdx) => renderLine(line, `top-${hunk.startIndex}-${lineIdx}`))}
{remainingCount > 0 && (
<div className="expand-marker">
<div className="expand-gutter">
<div className="expand-buttons">
{!isAtTop && (
<button className="expand-btn" onClick={() => expandUp(hunk.startIndex, hunk.count)} title="Expand up">
<IconChevronUp size={10} />
</button>
)}
{!isAtBottom && (
<button className="expand-btn" onClick={() => expandDown(hunk.startIndex, hunk.count)} title="Expand down">
<IconChevronDown size={10} />
</button>
)}
</div>
</div>
<div className="expand-line" />
</div>
)}
{bottomLines.map((line, lineIdx) => renderLine(line, `bottom-${hunk.startIndex}-${lineIdx}`))}
</React.Fragment>
);
});
return (
<StyledWrapper className={status || ''}>
<div className="diff-header">
<div className="diff-title">
<span className="diff-icon"><IconCode size={12} /></span>
{contentTypeLabel && <span className="diff-content-type">{contentTypeLabel}</span>}
<div className="diff-stats">
<span className="stat additions">+{diffResult.additions}</span>
<span className="stat deletions">-{diffResult.deletions}</span>
</div>
</div>
{renderActions()}
</div>
{warning && (
<div className={`diff-warning ${disableAccept ? 'error' : 'warn'}`}>
{warning}
</div>
)}
{isExpanded && <div className="diff-content">{renderHunks()}</div>}
<button className="diff-toggle" onClick={() => setIsExpanded((v) => !v)}>
{isExpanded ? (
<><IconChevronUp size={12} /> Hide</>
) : (
<><IconChevronDown size={12} /> Show ({diffResult.additions + diffResult.deletions})</>
)}
</button>
</StyledWrapper>
);
};
export default DiffView;

View File

@@ -0,0 +1,831 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
flex-shrink: 0;
height: 100%;
.ai-sidebar {
width: 420px;
height: 100%;
background: ${(props) => props.theme.bg};
border-left: 1px solid ${(props) => props.theme.border.border1};
display: flex;
flex-direction: column;
}
.ai-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
.header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.header-icon {
color: ${(props) => props.theme.brand};
flex-shrink: 0;
display: flex;
align-items: center;
}
.header-method {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
background: ${(props) => props.theme.background.surface0};
display: flex;
align-items: center;
&.method-get { color: ${(props) => props.theme.request.methods.get}; }
&.method-post { color: ${(props) => props.theme.request.methods.post}; }
&.method-put { color: ${(props) => props.theme.request.methods.put}; }
&.method-delete { color: ${(props) => props.theme.request.methods.delete}; }
&.method-patch { color: ${(props) => props.theme.request.methods.patch}; }
&.method-options { color: ${(props) => props.theme.request.methods.options}; }
&.method-head { color: ${(props) => props.theme.request.methods.head}; }
&.method-grpc { color: ${(props) => props.theme.request.grpc}; }
&.method-ws { color: ${(props) => props.theme.request.ws}; }
&.method-gql { color: ${(props) => props.theme.request.gql}; }
&.method-app { color: ${(props) => props.theme.brand}; }
}
.header-title {
font-size: 13px;
color: ${(props) => props.theme.text};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
}
.chat-switcher-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
flex-shrink: 0;
&:hover {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
}
.header-actions {
display: flex;
align-items: center;
gap: 2px;
}
.history-wrap {
position: relative;
}
.icon-btn {
position: relative;
padding: 6px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
&.is-active {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&.close-btn:hover {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
}
.history-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 20;
width: 300px;
max-height: 320px;
overflow-y: auto;
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
box-shadow: ${(props) => props.theme.shadow.md};
padding: 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.scrollbar.color};
border-radius: 2px;
}
&__empty {
padding: 16px;
text-align: center;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
&__item {
display: flex;
align-items: stretch;
gap: 2px;
border-radius: 4px;
&:hover {
background: ${(props) => props.theme.background.surface0};
}
&.is-active {
background: ${(props) => props.theme.background.surface0};
}
}
&__title {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 6px 8px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: ${(props) => props.theme.text};
}
&__title-text {
display: block;
width: 100%;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__meta {
font-size: 10px;
color: ${(props) => props.theme.colors.text.muted};
}
&__delete {
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
&:hover {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
}
.ai-sidebar-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.scrollbar.color};
border-radius: 2px;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px 16px;
animation: fadeIn 0.3s ease;
.empty-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: ${(props) => props.theme.brand};
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
margin-bottom: 12px;
}
h3 {
font-size: 14px;
font-weight: 600;
margin: 0 0 4px 0;
color: ${(props) => props.theme.text};
}
> p {
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
margin: 0 0 16px 0;
line-height: 1.4;
}
.suggestions-title {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
margin: 0 0 8px 0;
font-weight: 500;
}
.suggestion-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
}
.suggestion-chip {
padding: 5px 10px;
background: ${(props) => props.theme.background.surface0};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 12px;
font-size: 11px;
color: ${(props) => props.theme.text};
cursor: pointer;
&:hover {
border-color: ${(props) => props.theme.brand};
color: ${(props) => props.theme.brand};
}
}
}
.message {
animation: slideIn 0.25s ease;
&.user .message-content {
background: ${(props) => props.theme.background.mantle};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
line-height: 1.4;
color: ${(props) => props.theme.text};
}
&.assistant .message-content {
color: ${(props) => props.theme.text};
}
}
.message-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
margin-bottom: 6px;
color: ${(props) => props.theme.colors.text.muted};
&__spinner {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid ${(props) => props.theme.brand};
border-top-color: transparent;
animation: spin 0.9s linear infinite;
flex-shrink: 0;
}
}
.tool-activity-log {
display: flex;
flex-direction: column;
gap: 1px;
margin: 6px 0;
padding: 4px 0;
&.completed {
opacity: 0.7;
}
}
.tool-activity-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.6;
padding: 1px 0;
.tool-activity-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
}
&.done .tool-activity-indicator {
color: ${(props) => props.theme.colors.text.green};
}
&.active {
color: ${(props) => props.theme.text};
.tool-activity-indicator {
color: ${(props) => props.theme.brand};
}
}
.tool-activity-spinner {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid ${(props) => props.theme.brand};
border-top-color: transparent;
animation: spin 0.9s linear infinite;
display: block;
}
}
.message-cancelled {
margin-top: 8px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.assistant-code-block {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
background: ${(props) => props.theme.codemirror.bg};
overflow: hidden;
margin: 8px 0;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}
&__meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: ${(props) => props.theme.colors.text.muted};
}
&__lang {
text-transform: lowercase;
}
&__spinner {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid ${(props) => props.theme.brand};
border-top-color: transparent;
animation: spin 0.9s linear infinite;
flex-shrink: 0;
}
&__btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
background: ${(props) => props.theme.background.mantle};
font-size: 10px;
font-weight: 500;
color: ${(props) => props.theme.text};
cursor: pointer;
&:hover {
border-color: ${(props) => props.theme.brand};
color: ${(props) => props.theme.brand};
}
}
&__body {
margin: 0;
padding: 10px 12px;
overflow: auto;
max-height: 240px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre;
}
.cursor {
display: inline-block;
animation: blink 1s infinite;
color: ${(props) => props.theme.brand};
margin-left: 1px;
}
}
.prose.markdown-body {
font-size: 13px;
line-height: 1.5;
.cursor {
display: inline-block;
animation: blink 1s infinite;
color: ${(props) => props.theme.brand};
margin-left: 1px;
}
p {
margin: 0 0 8px 0;
font-size: 13px;
&:last-child { margin-bottom: 0; }
}
h1, h2, h3, h4, h5, h6 {
margin: 10px 0 6px 0;
font-weight: 600;
line-height: 1.3;
&:first-child { margin-top: 0; }
}
h1 { font-size: 1.3em; }
h2 { font-size: 1.2em; }
h3 { font-size: 1.1em; }
ul, ol {
margin: 6px 0;
padding-left: 16px;
}
li {
margin: 4px 0;
font-size: 13px;
}
code {
background: ${(props) => props.theme.codemirror.bg};
padding: 2px 5px;
border-radius: 4px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
}
pre, .code-block {
background: ${(props) => props.theme.codemirror.bg};
padding: 10px 12px;
border-radius: 6px;
overflow-x: auto;
margin: 8px 0;
border: 1px solid ${(props) => props.theme.border.border1};
code {
background: none;
padding: 0;
font-size: 11px;
line-height: 1.5;
}
}
blockquote {
border-left: 2px solid ${(props) => props.theme.brand};
margin: 8px 0;
padding: 4px 0 4px 10px;
color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.background.surface0};
border-radius: 0 4px 4px 0;
}
a {
color: ${(props) => props.theme.textLink};
text-decoration: none;
&:hover { text-decoration: underline; }
}
strong { font-weight: 600; }
em { font-style: italic; }
hr {
border: none;
border-top: 1px solid ${(props) => props.theme.border.border1};
margin: 10px 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
font-size: 12px;
}
th, td {
border: 1px solid ${(props) => props.theme.border.border1};
padding: 6px 8px;
text-align: left;
}
th {
background: ${(props) => props.theme.codemirror.bg};
font-weight: 600;
}
}
.processing-indicator {
padding: 8px 10px;
background: ${(props) => props.theme.background.surface0};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
animation: slideIn 0.2s ease;
.processing-content {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.processing-icon {
width: 20px;
height: 20px;
border-radius: 4px;
background: ${(props) => props.theme.background.surface1};
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.brand};
}
.processing-label {
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.text};
}
.processing-dots {
display: flex;
gap: 3px;
margin-left: 2px;
span {
width: 3px;
height: 3px;
background: ${(props) => props.theme.brand};
border-radius: 50%;
animation: dotBounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
}
}
.processing-bar {
height: 2px;
background: ${(props) => props.theme.border.border1};
border-radius: 1px;
overflow: hidden;
.processing-bar-fill {
height: 100%;
width: 30%;
background: ${(props) => props.theme.brand};
border-radius: 1px;
animation: progressSlide 1.5s infinite ease-in-out;
}
}
}
.error-message {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
background: ${(props) => props.theme.status.danger.background};
border: 1px solid ${(props) => props.theme.status.danger.border};
border-radius: 6px;
.error-icon {
width: 18px;
height: 18px;
border-radius: 50%;
background: ${(props) => props.theme.colors.text.danger};
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 11px;
flex-shrink: 0;
}
.error-text {
color: ${(props) => props.theme.colors.text.danger};
font-size: 12px;
line-height: 1.4;
}
}
.ai-sidebar-input {
padding: 12px;
border-top: 1px solid ${(props) => props.theme.border.border1};
.no-models-warning {
padding: 10px 12px;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.input.bg};
border: 1px dashed ${(props) => props.theme.border.border1};
border-radius: 6px;
text-align: center;
line-height: 1.4;
}
.input-container {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
&:focus-within {
border-color: ${(props) => props.theme.brand};
}
}
textarea {
width: 100%;
padding: 0;
margin: 4px 0;
border: none;
background: transparent;
color: ${(props) => props.theme.text};
font-size: 13px;
font-family: inherit;
line-height: 1.4;
resize: none;
outline: none;
max-height: 100px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:disabled {
opacity: 0.6;
}
}
.input-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.model-selector {
position: relative;
}
.model-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px 4px 8px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.base};
font-size: 11px;
font-weight: 500;
color: ${(props) => props.theme.text};
cursor: pointer;
svg:first-child {
color: ${(props) => props.theme.brand};
}
&:hover {
border-color: ${(props) => props.theme.border.border2};
}
}
.send-btn, .stop-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
}
.send-btn {
background: ${(props) => props.theme.brand};
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.stop-btn {
background: ${(props) => props.theme.colors.text.danger};
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
&:hover {
opacity: 0.9;
}
}
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
@keyframes dotBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes progressSlide {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,54 @@
export const PROCESSING_STAGES = [
{ id: 'sending', label: 'Sending request', icon: 'send' },
{ id: 'thinking', label: 'AI is thinking', icon: 'sparkles' },
{ id: 'generating', label: 'Generating response', icon: 'wand' },
{ id: 'applying', label: 'Preparing changes', icon: 'code' }
];
export const CONTENT_TYPE_LABELS = {
'app': 'App',
'tests': 'Tests',
'pre-request': 'Script',
'post-response': 'Script',
'docs': 'Docs'
};
export const SUGGESTIONS_BY_TYPE = {
'app': [
{ label: 'Create a form for this request', prompt: 'Create a simple form to send this request' },
{ label: 'Add a loading spinner', prompt: 'Add a loading spinner while the request is pending' },
{ label: 'Show response in a table', prompt: 'Display the response data in a table' },
{ label: 'Add error handling', prompt: 'Add error handling with user-friendly messages' }
],
'tests': [
{ label: 'Generate basic tests', prompt: 'Generate tests for status code, response body, and headers' },
{ label: 'Test response structure', prompt: 'Write tests to validate the response body structure and data types' },
{ label: 'Test error cases', prompt: 'Write tests for common error scenarios' },
{ label: 'Test response time', prompt: 'Add a test to verify response time is acceptable' }
],
'pre-request': [
{ label: 'Add authentication', prompt: 'Add authorization header from environment variable' },
{ label: 'Set dynamic variables', prompt: 'Set dynamic request variables like timestamp or unique ID' },
{ label: 'Conditional logic', prompt: 'Add conditional logic to modify the request based on environment' }
],
'post-response': [
{ label: 'Extract to variables', prompt: 'Extract data from response and save to environment variables' },
{ label: 'Store auth token', prompt: 'Extract auth token from response and save for future requests' },
{ label: 'Log response', prompt: 'Log response status and body for debugging' },
{ label: 'Transform response', prompt: 'Transform and process the response data' }
],
'docs': [
{ label: 'Generate full docs', prompt: 'Generate comprehensive API documentation for this endpoint' },
{ label: 'Document parameters', prompt: 'Document all request parameters, headers, and body' },
{ label: 'Add examples', prompt: 'Add request and response examples' },
{ label: 'Document errors', prompt: 'Document common error responses and status codes' }
]
};
export const PLACEHOLDER_BY_TYPE = {
'tests': { empty: 'Describe the tests you want...', filled: 'Ask to modify or add tests...' },
'pre-request': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' },
'post-response': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' },
'docs': { empty: 'Describe the documentation...', filled: 'Ask to update the docs...' },
'app': { empty: 'Describe the app you want to create...', filled: 'Ask to modify your app...' }
};

View File

@@ -0,0 +1,864 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconX,
IconPlayerStop,
IconCheck,
IconCode,
IconWand,
IconStars,
IconCornerDownLeft,
IconChevronDown,
IconHistory,
IconPlus,
IconTrash
} from '@tabler/icons';
import get from 'lodash/get';
import find from 'lodash/find';
import MenuDropdown from 'ui/MenuDropdown';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import {
closeAiSidebar,
sendAiMessage,
stopAiStream,
setChatBinding,
startNewConversation,
refreshChatHistory,
openConversation,
removeConversation,
setMessageCodeStatus
} from 'providers/ReduxStore/slices/chat';
import {
updateAppCode,
updateRequestTests,
updateRequestScript,
updateResponseScript,
updateRequestDocs,
updateFolderRequestScript,
updateFolderResponseScript,
updateFolderTests,
updateFolderDocs,
updateCollectionRequestScript,
updateCollectionResponseScript,
updateCollectionTests,
updateCollectionDocs
} from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, isItemAFolder, isItemARequest } from 'utils/collections';
import { buildAiVariablesPayload, getAiStatus } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import DiffView from './DiffView';
import AssistantCodeBlock from './AssistantCodeBlock';
import { PROCESSING_STAGES, CONTENT_TYPE_LABELS, SUGGESTIONS_BY_TYPE, PLACEHOLDER_BY_TYPE } from './constants';
import { renderMarkdown, parseMessageSegments } from './utils';
const SELECTED_MODEL_LS_KEY = 'bruno.ai.chat.selectedModel';
const AUTO_MODEL_ID = '';
const ToolActivityGroup = ({ activities }) => {
if (!activities?.length) return null;
const allDone = activities.every((a) => a.done);
return (
<div className={`tool-activity-log ${allDone ? 'completed' : ''}`}>
{activities.map((activity, i) => (
<div key={i} className={`tool-activity-item ${activity.done ? 'done' : 'active'}`}>
<span className="tool-activity-indicator">
{activity.done ? <IconCheck size={10} /> : <span className="tool-activity-spinner" />}
</span>
<span>{activity.label}{!activity.done ? '…' : ''}</span>
</div>
))}
</div>
);
};
const buildMessageTimeline = (cleanedContent, activities) => {
if (!activities?.length) {
return cleanedContent ? [{ type: 'text', content: cleanedContent }] : [];
}
if (!cleanedContent) return [{ type: 'tools', activities }];
const groups = [];
for (const activity of activities) {
const offset = Math.min(activity.textOffset || 0, cleanedContent.length);
const last = groups[groups.length - 1];
if (last && last.offset === offset) last.activities.push(activity);
else groups.push({ offset, activities: [activity] });
}
const parts = [];
let cursor = 0;
for (const group of groups) {
if (group.offset > cursor) {
parts.push({ type: 'text', content: cleanedContent.substring(cursor, group.offset) });
}
parts.push({ type: 'tools', activities: group.activities });
cursor = Math.max(cursor, group.offset);
}
if (cursor < cleanedContent.length) {
parts.push({ type: 'text', content: cleanedContent.substring(cursor) });
}
return parts;
};
const formatRelativeTime = (timestamp) => {
if (!timestamp) return '';
const diff = Date.now() - timestamp;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return 'just now';
if (diff < hour) return `${Math.floor(diff / minute)}m ago`;
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`;
return new Date(timestamp).toLocaleDateString();
};
const HistoryPopover = ({ items, activeId, onPick, onDelete, onClose }) => {
const popoverRef = useRef(null);
useEffect(() => {
const handleClick = (e) => {
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
onClose();
}
};
const handleKey = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [onClose]);
return (
<div className="history-popover" ref={popoverRef} role="menu">
{items.length === 0 ? (
<div className="history-popover__empty">No past conversations</div>
) : (
items.map((item) => (
<div
key={item.id}
className={`history-popover__item ${item.id === activeId ? 'is-active' : ''}`}
role="menuitem"
>
<button className="history-popover__title" onClick={() => onPick(item.id)} title={item.title}>
<span className="history-popover__title-text">{item.title || '(untitled)'}</span>
<span className="history-popover__meta">{formatRelativeTime(item.updatedAt)}</span>
</button>
<button
className="history-popover__delete"
onClick={(e) => {
e.stopPropagation(); onDelete(item.id);
}}
title="Delete conversation"
aria-label="Delete conversation"
>
<IconTrash size={12} />
</button>
</div>
))
)}
</div>
);
};
const AiChatSidebar = ({ collection }) => {
const dispatch = useDispatch();
const [input, setInput] = useState('');
const [processingStage, setProcessingStage] = useState(null);
const [availableModels, setAvailableModels] = useState([]);
const [selectedModel, setSelectedModel] = useState(() => {
try { return localStorage.getItem(SELECTED_MODEL_LS_KEY) ?? AUTO_MODEL_ID; } catch { return AUTO_MODEL_ID; }
});
const [historyOpen, setHistoryOpen] = useState(false);
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const isNearBottomRef = useRef(true);
const textareaRef = useRef(null);
const isOpen = useSelector((state) => state.chat.isOpen);
const allChats = useSelector((state) => state.chat.chats);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const preferences = useSelector((state) => state.app.preferences);
const aiEnabled = get(preferences, 'ai.enabled', false);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const activeItem = focusedTab && collection ? findItemInCollection(collection, activeTabUid) : null;
const aiContext = useMemo(() => {
if (!focusedTab || !collection) return null;
if (activeItem && (isItemARequest(activeItem) || activeItem.type === 'app')) {
return { kind: 'request', item: activeItem, pathname: activeItem.pathname || '', name: activeItem.name || 'Untitled' };
}
if (activeItem && isItemAFolder(activeItem)) {
return { kind: 'folder', folder: activeItem, pathname: activeItem.pathname || '', name: activeItem.name || 'Untitled' };
}
// Anything else (collection-settings, runner, variables, openapi-sync,
// .js files in File Mode …) falls back to the collection root so the AI
// button always opens a useful chat instead of a no-op.
return { kind: 'collection', pathname: collection.pathname || '', name: collection.name || 'Untitled Collection' };
}, [focusedTab, collection, activeItem]);
const currentChat = allChats[activeTabUid] || { messages: [], isLoading: false, error: null, historyList: [] };
const { messages, isLoading, error, historyList, conversationId } = currentChat;
useEffect(() => {
if (!isOpen || !aiEnabled) return;
let cancelled = false;
getAiStatus()
.then((status) => {
if (cancelled) return;
setAvailableModels(status?.availableModels || []);
})
.catch(() => {
if (!cancelled) setAvailableModels([]);
});
return () => { cancelled = true; };
}, [isOpen, aiEnabled, preferences?.ai]);
// Auto = empty string. We don't auto-correct to the first model — let the
// backend pick, so users get smart defaults that adapt as providers change.
useEffect(() => {
if (selectedModel === AUTO_MODEL_ID) return;
if (availableModels.length === 0) return;
if (availableModels.some((m) => m.id === selectedModel)) return;
setSelectedModel(AUTO_MODEL_ID);
try { localStorage.setItem(SELECTED_MODEL_LS_KEY, AUTO_MODEL_ID); } catch {}
}, [availableModels, selectedModel]);
const requestName = aiContext?.name || activeItem?.name || 'Untitled';
const requestMethod = useMemo(() => {
if (aiContext?.kind === 'folder') return 'FOLDER';
if (aiContext?.kind === 'collection') return 'ROOT';
if (!activeItem) return 'GET';
if (activeItem.type === 'grpc-request') return 'GRPC';
if (activeItem.type === 'ws-request') return 'WS';
if (activeItem.type === 'graphql-request') return 'GQL';
if (activeItem.type === 'app') return 'APP';
const appOn = activeItem.draft
? get(activeItem, 'draft.app.enabled', false)
: get(activeItem, 'app.enabled', false);
if (appOn) return 'APP';
return activeItem.draft
? get(activeItem, 'draft.request.method', 'GET')
: get(activeItem, 'request.method', 'GET');
}, [aiContext?.kind, activeItem]);
// contentType drives the AI prompt, the diff target, and which entry of
// allContent the backend treats as "active". For requests it follows the
// request-pane tab. For folders / collections we read the settings sub-tab
// (and the inner pre/post script split for the Script sub-tab).
const requestPaneTab = focusedTab?.requestPaneTab;
const scriptPaneTab = focusedTab?.scriptPaneTab;
const contentType = useMemo(() => {
if (aiContext?.kind === 'folder') {
const sub = collection?.folderLevelSettingsSelectedTab?.[aiContext.folder.uid];
if (sub === 'test') return 'tests';
if (sub === 'docs') return 'docs';
if (sub === 'script') return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request';
return 'pre-request';
}
if (aiContext?.kind === 'collection') {
const sub = collection?.settingsSelectedTab;
if (sub === 'tests') return 'tests';
if (sub === 'overview') return 'docs';
if (sub === 'script') return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request';
return 'pre-request';
}
switch (requestPaneTab) {
case 'tests': return 'tests';
case 'script': return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request';
case 'docs': return 'docs';
default: return 'app';
}
}, [aiContext, collection?.folderLevelSettingsSelectedTab, collection?.settingsSelectedTab, requestPaneTab, scriptPaneTab]);
// Bind the chat to the active context's pathname so the history list
// reflects this specific request/folder/collection and persistence keys stay
// stable across sessions. Restoring the most recent conversation happens
// once per tab — if the user explicitly starts a new chat, we don't
// auto-replace it.
const restoredOnceRef = useRef({});
useEffect(() => {
if (!isOpen || !aiContext || !collection) return;
dispatch(setChatBinding({
tabUid: activeTabUid,
pathname: aiContext.pathname,
collectionUid: collection.uid,
contentType
}));
dispatch(refreshChatHistory(activeTabUid));
}, [isOpen, aiContext?.pathname, collection?.uid, activeTabUid, contentType, dispatch]);
// First-open restore: if this tab has no conversation yet and there's a
// saved one for the same file, load the most recent.
useEffect(() => {
if (!isOpen || !activeTabUid) return;
if (restoredOnceRef.current[activeTabUid]) return;
if (currentChat.conversationId) return;
if (currentChat.messages?.length > 0) return;
if (!historyList || historyList.length === 0) return;
restoredOnceRef.current[activeTabUid] = true;
dispatch(openConversation(activeTabUid, historyList[0].id));
}, [isOpen, activeTabUid, currentChat.conversationId, currentChat.messages?.length, historyList, dispatch]);
const allContent = useMemo(() => {
if (!aiContext) return {};
if (aiContext.kind === 'request') {
const item = aiContext.item;
const draft = item.draft;
const draftAppCode = get(item, 'draft.app.code');
return {
'app': draftAppCode != null ? draftAppCode : get(item, 'app.code', ''),
'tests': draft ? get(draft, 'request.tests', '') : get(item, 'request.tests', ''),
'pre-request': draft ? get(draft, 'request.script.req', '') : get(item, 'request.script.req', ''),
'post-response': draft ? get(draft, 'request.script.res', '') : get(item, 'request.script.res', ''),
'docs': draft ? get(draft, 'request.docs', '') : get(item, 'request.docs', '')
};
}
if (aiContext.kind === 'folder') {
const folder = aiContext.folder;
const root = folder.draft || folder.root || {};
return {
'tests': get(root, 'request.tests', ''),
'pre-request': get(root, 'request.script.req', ''),
'post-response': get(root, 'request.script.res', ''),
'docs': get(root, 'docs', '')
};
}
// collection
const root = collection?.draft?.root || collection?.root || {};
return {
'tests': get(root, 'request.tests', ''),
'pre-request': get(root, 'request.script.req', ''),
'post-response': get(root, 'request.script.res', ''),
'docs': get(root, 'docs', '')
};
}, [aiContext, collection?.draft?.root, collection?.root]);
const currentContent = allContent[contentType] || '';
// requestContext (URL/method/headers/response shape) only makes sense for
// HTTP-style request items. Folder, collection, and App chats skip it —
// App items live under kind: 'request' but have no URL/method to surface.
const requestContext = useMemo(() => {
if (aiContext?.kind !== 'request' || !isItemARequest(aiContext.item)) return null;
const item = aiContext.item;
const draft = item.draft;
return {
url: draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', ''),
method: draft ? get(item, 'draft.request.method', '') : get(item, 'request.method', ''),
headers: draft ? get(item, 'draft.request.headers', []) : get(item, 'request.headers', []),
params: draft ? get(item, 'draft.request.params', []) : get(item, 'request.params', []),
body: draft ? get(item, 'draft.request.body', null) : get(item, 'request.body', null),
docs: draft ? get(item, 'draft.request.docs', null) : get(item, 'request.docs', null),
responseStatus: get(item, 'response.status', null),
responseData: get(item, 'response.data', null)
};
}, [aiContext]);
// Variables payload is collection-scoped — works for request, folder, and
// collection chats alike. Each entry is { name, value, scope, secret }; the
// model gets a name-only preview in the prompt and can call search_variables
// to fetch values (secrets come back redacted).
const aiVariables = useMemo(() => {
if (aiContext?.kind === 'request') return buildAiVariablesPayload(collection, aiContext.item);
if (aiContext?.kind === 'folder') return buildAiVariablesPayload(collection, aiContext.folder);
return buildAiVariablesPayload(collection, null);
}, [collection, aiContext]);
const chatsWithMessages = useMemo(() => {
if (!collection) return [];
return Object.entries(allChats)
.filter(([, chat]) => chat.messages?.length > 0)
.map(([tabUid, chat]) => {
if (tabUid === collection.uid) {
return { id: tabUid, name: collection.name || 'Untitled Collection', method: 'ROOT', messageCount: chat.messages.length };
}
const item = findItemInCollection(collection, tabUid);
if (!item) return null;
if (isItemAFolder(item)) {
return { id: tabUid, name: item.name || 'Untitled', method: 'FOLDER', messageCount: chat.messages.length };
}
const method = item.draft
? get(item, 'draft.request.method', 'GET')
: get(item, 'request.method', 'GET');
return {
id: tabUid,
name: item.name || 'Untitled',
method,
messageCount: chat.messages.length
};
})
.filter(Boolean);
}, [allChats, collection]);
const scrollToBottom = useCallback((behavior = 'smooth') => {
messagesEndRef.current?.scrollIntoView({ behavior });
}, []);
const handleMessagesScroll = useCallback(() => {
const el = messagesContainerRef.current;
if (!el) return;
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}, []);
useEffect(() => {
if (!isNearBottomRef.current) return;
const behavior = messages.some((m) => m.isStreaming) ? 'auto' : 'smooth';
scrollToBottom(behavior);
}, [messages, scrollToBottom]);
useEffect(() => {
if (isOpen) textareaRef.current?.focus();
}, [isOpen]);
useEffect(() => {
if (!isLoading) {
setProcessingStage(null);
return;
}
const last = messages[messages.length - 1];
if (last?.isStreaming && last.content) setProcessingStage('generating');
else if (last?.isStreaming) setProcessingStage('thinking');
else setProcessingStage('sending');
}, [isLoading, messages]);
const handleTextareaChange = (e) => {
setInput(e.target.value);
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
}
};
const handleSubmit = async (e) => {
e?.preventDefault();
if (!input.trim() || isLoading || availableModels.length === 0) return;
const text = input.trim();
setInput('');
setProcessingStage('sending');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
try {
await dispatch(sendAiMessage(activeTabUid, text, allContent, requestContext, selectedModel, contentType, aiVariables));
setProcessingStage('applying');
setTimeout(() => setProcessingStage(null), 500);
} catch (err) {
console.error('Failed to send AI message:', err);
setProcessingStage(null);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleStop = () => {
dispatch(stopAiStream(activeTabUid));
setProcessingStage(null);
};
const handleApplyCode = (code, originalCode, messageIndex, msgContentType, writeIndex) => {
if (!aiContext || code == null) return;
const targetType = msgContentType || contentType;
// Bail if the live buffer has drifted from what the AI based the diff on.
// The DiffView already disables the button in this case, but guarding here
// too means the keyboard / programmatic path can't blow away local edits.
const liveContent = allContent[targetType] || '';
if (originalCode != null && liveContent !== originalCode) {
return;
}
if (aiContext.kind === 'request') {
const payload = { itemUid: aiContext.item.uid, collectionUid: collection.uid };
switch (targetType) {
case 'tests': dispatch(updateRequestTests({ ...payload, tests: code })); break;
case 'pre-request': dispatch(updateRequestScript({ ...payload, script: code })); break;
case 'post-response': dispatch(updateResponseScript({ ...payload, script: code })); break;
case 'docs': dispatch(updateRequestDocs({ ...payload, docs: code })); break;
default: dispatch(updateAppCode({ ...payload, code })); break;
}
} else if (aiContext.kind === 'folder') {
const payload = { folderUid: aiContext.folder.uid, collectionUid: collection.uid };
switch (targetType) {
case 'tests': dispatch(updateFolderTests({ ...payload, tests: code })); break;
case 'pre-request': dispatch(updateFolderRequestScript({ ...payload, script: code })); break;
case 'post-response': dispatch(updateFolderResponseScript({ ...payload, script: code })); break;
case 'docs': dispatch(updateFolderDocs({ ...payload, docs: code })); break;
// Folders / collections have no 'app' equivalent. Bail rather than
// marking the diff accepted when nothing was dispatched.
default: return;
}
} else {
const payload = { collectionUid: collection.uid };
switch (targetType) {
case 'tests': dispatch(updateCollectionTests({ ...payload, tests: code })); break;
case 'pre-request': dispatch(updateCollectionRequestScript({ ...payload, script: code })); break;
case 'post-response': dispatch(updateCollectionResponseScript({ ...payload, script: code })); break;
case 'docs': dispatch(updateCollectionDocs({ ...payload, docs: code })); break;
default: return;
}
}
dispatch(setMessageCodeStatus({
tabUid: activeTabUid,
messageIndex,
status: 'accepted',
writeIndex
}));
};
const handleRejectCode = (messageIndex, writeIndex) => {
dispatch(setMessageCodeStatus({
tabUid: activeTabUid,
messageIndex,
status: 'rejected',
writeIndex
}));
};
const handleNewChat = () => {
setHistoryOpen(false);
restoredOnceRef.current[activeTabUid] = true; // suppress restore
dispatch(startNewConversation({ tabUid: activeTabUid, contentType }));
textareaRef.current?.focus();
};
const handlePickConversation = (id) => {
setHistoryOpen(false);
restoredOnceRef.current[activeTabUid] = true;
dispatch(openConversation(activeTabUid, id));
};
const handleDeleteConversation = (id) => {
dispatch(removeConversation(activeTabUid, id));
};
const handleClose = () => dispatch(closeAiSidebar());
const handleSwitchChat = (tabUid) => dispatch(focusTab({ uid: tabUid }));
const handleSuggestionClick = (suggestion) => {
setInput(suggestion);
textareaRef.current?.focus();
};
const handleModelSelect = (modelId) => {
setSelectedModel(modelId);
try { localStorage.setItem(SELECTED_MODEL_LS_KEY, modelId); } catch {}
};
const selectedModelLabel = useMemo(() => {
if (selectedModel === AUTO_MODEL_ID) return 'Auto';
return availableModels.find((m) => m.id === selectedModel)?.label || 'Auto';
}, [availableModels, selectedModel]);
const ModelSelectorTrigger = forwardRef((props, ref) => (
<div ref={ref} className="model-btn" {...props}>
<IconStars size={14} strokeWidth={1.75} />
<span>{selectedModelLabel}</span>
<IconChevronDown size={12} />
</div>
));
ModelSelectorTrigger.displayName = 'ModelSelectorTrigger';
const modelMenuItems = useMemo(
() => [
{ id: AUTO_MODEL_ID, label: 'Auto', onClick: () => handleModelSelect(AUTO_MODEL_ID) },
...availableModels.map((model) => ({
id: model.id,
label: model.label,
onClick: () => handleModelSelect(model.id)
}))
],
[availableModels]
);
const hasActiveStream = messages.some((m) => m.isStreaming);
const renderProcessingIndicator = () => {
if (!processingStage || processingStage === 'thinking' || hasActiveStream) return null;
const stage = PROCESSING_STAGES.find((s) => s.id === processingStage) || PROCESSING_STAGES[0];
return (
<div className="processing-indicator">
<div className="processing-content">
<div className="processing-icon">
{stage.icon === 'sparkles' && <IconStars size={12} />}
{stage.icon === 'wand' && <IconWand size={12} />}
{stage.icon === 'code' && <IconCode size={12} />}
{stage.icon === 'send' && <IconCornerDownLeft size={12} />}
</div>
<span className="processing-label">{stage.label}</span>
<div className="processing-dots"><span></span><span></span><span></span></div>
</div>
<div className="processing-bar"><div className="processing-bar-fill"></div></div>
</div>
);
};
const renderMessage = (msg, index) => {
const isUser = msg.role === 'user';
const isStreaming = msg.isStreaming;
const activities = msg.toolActivity || [];
const hasPendingTool = activities.some((a) => !a.done);
const content = msg.content || '';
const showThinking = isStreaming && !content && activities.length === 0;
const showWorking = isStreaming && activities.length > 0 && !hasPendingTool;
const timeline = buildMessageTimeline(content, activities);
return (
<div key={index} className={`message ${msg.role} ${isStreaming ? 'streaming' : ''}`}>
<div className="message-content">
{isUser ? content : (
<>
{showThinking && (
<div className="message-status">
<span className="message-status__spinner" />
<span>Thinking</span>
</div>
)}
{timeline.map((part, partIndex) => {
if (part.type === 'tools') {
return <ToolActivityGroup key={`tools-${partIndex}`} activities={part.activities} />;
}
const segments = parseMessageSegments(part.content);
const isLastTextPart = !timeline.slice(partIndex + 1).some((p) => p.type === 'text');
return (
<React.Fragment key={`text-${partIndex}`}>
{segments.map((segment, segIndex) => {
const isLastSegment = isLastTextPart && segIndex === segments.length - 1;
if (segment.type === 'code') {
return (
<AssistantCodeBlock
key={`p${partIndex}-s${segIndex}`}
content={segment.content}
language={segment.language}
isOpen={segment.isOpen}
isStreaming={isStreaming}
isLast={isLastSegment}
/>
);
}
return (
<div key={`p${partIndex}-s${segIndex}`} className="prose markdown-body">
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(segment.content) }} />
{isStreaming && isLastSegment && <span className="cursor">|</span>}
</div>
);
})}
</React.Fragment>
);
})}
{showWorking && (
<div className="message-status">
<span className="message-status__spinner" />
<span>Working</span>
</div>
)}
{!isStreaming && msg.writes?.length > 0 && msg.writes.map((write, writeIdx) => {
if (write.content === write.originalContent) return null;
const liveContent = allContent[write.type] || '';
const isStale = liveContent !== write.originalContent;
const notRead = !write.wasRead;
return (
<DiffView
key={`write-${writeIdx}`}
originalCode={write.originalContent || ''}
newCode={write.content}
contentTypeLabel={CONTENT_TYPE_LABELS[write.type] || write.type}
warning={
notRead ? 'Content was not read first — changes may overwrite unrelated edits'
: isStale ? 'Content has been modified since AI read it'
: null
}
disableAccept={isStale || notRead}
onAccept={() => handleApplyCode(write.content, write.originalContent, index, write.type, writeIdx)}
onReject={() => handleRejectCode(index, writeIdx)}
status={write.status}
/>
);
})}
{!isStreaming && !msg.writes && msg.code && msg.originalCode && msg.code !== msg.originalCode && (
<DiffView
originalCode={msg.originalCode || ''}
newCode={msg.code}
onAccept={() => handleApplyCode(msg.code, msg.originalCode, index, msg.contentType)}
onReject={() => handleRejectCode(index)}
status={msg.codeStatus}
/>
)}
{!isStreaming && msg.cancelled && (
<div className="message-cancelled"><em>Cancelled</em></div>
)}
</>
)}
</div>
</div>
);
};
const renderEmptyState = () => {
const suggestions = SUGGESTIONS_BY_TYPE[contentType] || SUGGESTIONS_BY_TYPE.app;
return (
<div className="empty-state">
<div className="empty-icon"><IconStars size={20} /></div>
<h3>AI Assistant</h3>
<p>Ask me to generate or modify code, tests, scripts, and docs.</p>
<div className="suggestions">
<p className="suggestions-title">Try asking:</p>
<div className="suggestion-chips">
{suggestions.map((s, i) => (
<button key={i} className="suggestion-chip" onClick={() => handleSuggestionClick(s.prompt)}>
{s.label}
</button>
))}
</div>
</div>
</div>
);
};
if (!isOpen) return null;
if (!aiContext) return null;
const placeholders = PLACEHOLDER_BY_TYPE[contentType] || PLACEHOLDER_BY_TYPE.app;
const placeholder = currentContent ? placeholders.filled : placeholders.empty;
const historyCount = historyList?.length || 0;
return (
<StyledWrapper>
<div className="ai-sidebar">
<div className="ai-sidebar-header">
<div className="header-left">
<IconStars size={18} className="header-icon" />
<span className={`header-method method-${(requestMethod || 'get').toLowerCase()}`}>{requestMethod}</span>
<span className="header-title">{requestName}</span>
{chatsWithMessages.length > 1 && (
<MenuDropdown
items={chatsWithMessages.map((chat) => ({
id: chat.id,
label: `${chat.method} · ${chat.name}`,
onClick: () => handleSwitchChat(chat.id)
}))}
placement="bottom-start"
selectedItemId={activeTabUid}
>
<button className="chat-switcher-btn" title="Switch chat">
<IconChevronDown size={14} />
</button>
</MenuDropdown>
)}
</div>
<div className="header-actions">
<button
className="icon-btn"
onClick={handleNewChat}
title="New chat"
disabled={isLoading || messages.length === 0}
>
<IconPlus size={14} />
</button>
<div className="history-wrap">
<button
className={`icon-btn ${historyOpen ? 'is-active' : ''}`}
onClick={() => setHistoryOpen((v) => !v)}
title="History"
disabled={historyCount === 0}
>
<IconHistory size={14} />
</button>
{historyOpen && (
<HistoryPopover
items={historyList || []}
activeId={conversationId}
onPick={handlePickConversation}
onDelete={handleDeleteConversation}
onClose={() => setHistoryOpen(false)}
/>
)}
</div>
<button className="icon-btn close-btn" onClick={handleClose} title="Close">
<IconX size={14} />
</button>
</div>
</div>
<div className="ai-sidebar-messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
{messages.length === 0 ? renderEmptyState() : (
<>
{messages.map(renderMessage)}
{renderProcessingIndicator()}
</>
)}
{error && (
<div className="error-message">
<div className="error-icon">!</div>
<div className="error-text">{error}</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="ai-sidebar-input">
{availableModels.length === 0 ? (
<div className="no-models-warning">
No AI models available. Configure a provider and enable models in Preferences &gt; AI.
</div>
) : (
<div className="input-container">
<textarea
ref={textareaRef}
value={input}
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading}
rows={1}
/>
<div className="input-actions">
<div className="model-selector">
<MenuDropdown items={modelMenuItems} placement="top-start" selectedItemId={selectedModel}>
<ModelSelectorTrigger />
</MenuDropdown>
</div>
{isLoading ? (
<button className="stop-btn" onClick={handleStop} title="Stop generating">
<IconPlayerStop size={12} /> Stop
</button>
) : (
<button
className="send-btn"
onClick={handleSubmit}
title="Send (Enter)"
disabled={!input.trim()}
>
Send <IconCornerDownLeft size={12} />
</button>
)}
</div>
</div>
)}
</div>
</div>
</StyledWrapper>
);
};
export default AiChatSidebar;

View File

@@ -0,0 +1,63 @@
import MarkdownIt from 'markdown-it';
const SAFE_LANG = /^[a-z0-9_+#.-]+$/i;
const safeLanguage = (lang) => (lang && SAFE_LANG.test(lang) ? lang : 'text');
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
highlight: (str, lang) =>
`<pre class="code-block"><code class="language-${safeLanguage(lang)}">${md.utils.escapeHtml(str)}</code></pre>`
});
export const renderMarkdown = (content) => md.render(content || '');
export const parseMessageSegments = (content = '') => {
if (!content) return [];
const segments = [];
let cursor = 0;
let inCode = false;
let language = '';
while (cursor <= content.length) {
const fenceIndex = content.indexOf('```', cursor);
if (fenceIndex === -1) {
const chunk = content.slice(cursor);
if (inCode || chunk) {
segments.push({
type: inCode ? 'code' : 'text',
content: chunk,
language,
isOpen: inCode
});
}
break;
}
if (!inCode) {
const textChunk = content.slice(cursor, fenceIndex);
if (textChunk) {
segments.push({ type: 'text', content: textChunk });
}
const fenceEnd = fenceIndex + 3;
const lineEnd = content.indexOf('\n', fenceEnd);
language = (lineEnd === -1 ? content.slice(fenceEnd) : content.slice(fenceEnd, lineEnd)).trim();
inCode = true;
cursor = lineEnd === -1 ? content.length : lineEnd + 1;
} else {
const codeChunk = content.slice(cursor, fenceIndex);
if (codeChunk.trim()) {
segments.push({ type: 'code', content: codeChunk, language, isOpen: false });
}
inCode = false;
language = '';
cursor = fenceIndex + 3;
if (content[cursor] === '\n') cursor += 1;
}
}
return segments.filter((seg) => seg.content && seg.content.trim());
};

View File

@@ -683,8 +683,6 @@ const StyledWrapper = styled.div`
.copy-to-clipboard {
button {
background: ${(props) => props.theme.background.mantle};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
}
}

View File

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

View File

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

View File

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

View File

@@ -52,6 +52,14 @@ const AppTitleBar = () => {
const { ipcRenderer } = window;
if (!ipcRenderer) return;
ipcRenderer.invoke('renderer:window-is-fullscreen')
.then((fullscreen) => {
setIsFullScreen(fullscreen);
})
.catch((error) => {
console.error('Error getting initial fullscreen state:', error);
});
const removeEnterFullScreenListener = ipcRenderer.on('main:enter-full-screen', () => {
setIsFullScreen(true);
});
@@ -138,14 +146,18 @@ const AppTitleBar = () => {
};
const handleWorkspaceSwitch = (workspaceUid) => {
if (workspaceUid === activeWorkspaceUid) return;
dispatch(switchWorkspace(workspaceUid));
toast.success(`Switched to ${getWorkspaceDisplayName(workspaces.find((w) => w.uid === workspaceUid)?.name)}`);
};
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

@@ -0,0 +1,48 @@
import React from 'react';
import styled from 'styled-components';
import { IconAppWindow } from '@tabler/icons';
const Wrapper = styled.div`
flex: 1 1 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed ${(props) => props.theme.border.border1};
border-radius: 4px;
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.colors.text.muted};
.empty-app-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem;
text-align: center;
max-width: 360px;
}
.empty-app-title {
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.text};
}
.empty-app-hint {
font-size: 12px;
line-height: 1.4;
}
`;
const EmptyAppState = ({ title = 'No app yet', hint }) => (
<Wrapper data-testid="empty-app-state">
<div className="empty-app-inner">
<IconAppWindow size={32} strokeWidth={1.25} />
<div className="empty-app-title">{title}</div>
{hint ? <div className="empty-app-hint">{hint}</div> : null}
</div>
</Wrapper>
);
export default EmptyAppState;

View File

@@ -0,0 +1,52 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
flex-grow: 1;
padding: 0.5rem;
.app-view-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem 0.4rem;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.app-view-toolbar .app-exit-btn {
cursor: pointer;
padding: 2px 8px;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
&:hover {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.text};
}
}
.app-webview-container {
flex: 1 1 0;
min-height: 0;
display: flex;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
overflow: hidden;
background: ${(props) => props.theme.background.surface0};
}
.app-webview {
width: 100%;
height: 100%;
flex: 1 1 0;
border: 0;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,307 @@
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { useDispatch } from 'react-redux';
import { sendNetworkRequest } from 'utils/network/index';
import {
findEnvironmentInCollection,
getEnvironmentVariables,
getGlobalEnvironmentVariables
} from 'utils/collections';
import {
responseReceived,
appSetRuntimeVariable,
toggleAppMode,
initRunRequestEvent
} from 'providers/ReduxStore/slices/collections';
import { uuid } from 'utils/common';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import EmptyAppState from './EmptyAppState';
import {
SENTINEL,
wrapHtml,
toDataUrl,
serializeTimeline,
projectResponse,
useAppWebview
} from './webview-bridge';
// Request-level ctx bootstrap. Injected into the guest so window.ctx exists
// before user scripts run.
const REQUEST_CTX_BOOTSTRAP = `<script>
(function () {
if (window.__brunoBootstrapped) return;
window.__brunoBootstrapped = true;
var SENTINEL = ${JSON.stringify(SENTINEL)};
var pending = new Map();
var nextRequestId = 0;
function sendToHost(payload) {
try { console.log(SENTINEL + JSON.stringify(payload)); } catch (e) {}
}
var ctx = {
theme: 'light',
response: null,
assertionResults: [],
testResults: [],
variables: {},
onThemeChange: null,
onResponseUpdate: null,
onResultsUpdate: null,
onVariablesUpdate: null,
sendRequest: function (overrides) {
return new Promise(function (resolve, reject) {
var requestId = ++nextRequestId;
pending.set(requestId, { resolve: resolve, reject: reject });
sendToHost({ type: 'sendRequest', requestId: requestId, overrides: overrides || {} });
});
},
setRuntimeVariable: function (key, value) {
sendToHost({ type: 'setRuntimeVariable', key: String(key), value: value });
},
log: function () {
var args = Array.prototype.slice.call(arguments);
sendToHost({ type: 'log', args: args });
}
};
window.ctx = ctx;
function applyTheme(theme) {
ctx.theme = theme || 'light';
if (document.body) {
document.body.classList.remove('light', 'dark');
document.body.classList.add(ctx.theme);
}
}
window.__brunoReceive = function (msg) {
if (!msg) return;
switch (msg.type) {
case 'state':
applyTheme(msg.theme);
ctx.response = msg.response || null;
ctx.assertionResults = msg.assertionResults || [];
ctx.testResults = msg.testResults || [];
ctx.variables = msg.variables || {};
break;
case 'theme':
applyTheme(msg.theme);
if (typeof ctx.onThemeChange === 'function') ctx.onThemeChange(ctx.theme);
break;
case 'responseUpdate':
ctx.response = msg.response || null;
if (typeof ctx.onResponseUpdate === 'function') ctx.onResponseUpdate(ctx.response);
break;
case 'results':
ctx.assertionResults = msg.assertionResults || [];
ctx.testResults = msg.testResults || [];
if (typeof ctx.onResultsUpdate === 'function') {
ctx.onResultsUpdate({ assertionResults: ctx.assertionResults, testResults: ctx.testResults });
}
break;
case 'variables':
ctx.variables = msg.variables || {};
if (typeof ctx.onVariablesUpdate === 'function') ctx.onVariablesUpdate(ctx.variables);
break;
case 'response': {
var entry = pending.get(msg.requestId);
if (!entry) return;
pending.delete(msg.requestId);
if (msg.error) entry.reject(new Error(msg.error));
else entry.resolve(msg.response);
break;
}
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { sendToHost({ type: 'ready' }); });
} else {
sendToHost({ type: 'ready' });
}
})();
</script>`;
const buildVariables = (collection) => {
const env = getEnvironmentVariables(collection);
const global = getGlobalEnvironmentVariables({
globalEnvironments: collection?.globalEnvironments || [],
activeGlobalEnvironmentUid: collection?.activeGlobalEnvironmentUid
});
return {
...global,
...env,
...(collection?.collectionVariables || {}),
...(collection?.runtimeVariables || {})
};
};
const AppView = ({ item, collection, code }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const src = useMemo(() => toDataUrl(wrapHtml(REQUEST_CTX_BOOTSTRAP, code || '')), [code]);
const environment = useMemo(
() => findEnvironmentInCollection(collection, collection.activeEnvironmentUid),
[collection]
);
const variables = useMemo(() => buildVariables(collection), [collection]);
const response = useMemo(() => (item.response ? projectResponse(item.response) : null), [item.response]);
const assertionResults = useMemo(() => item.assertionResults || [], [item.assertionResults]);
const testResults = useMemo(() => item.testResults || [], [item.testResults]);
// pushToGuest is produced by useAppWebview, which itself needs handleGuestMessage —
// routing through a ref lets the callbacks call the *latest* pushToGuest without
// creating a circular useCallback dependency. Without this, the request-id reply
// (and error reply) close over the first-render no-op pushToGuest and the guest's
// ctx.sendRequest() promise never resolves.
const pushToGuestRef = useRef(() => {});
const handleSendRequest = useCallback(
async (requestId, overrides) => {
const push = pushToGuestRef.current;
try {
// Mint a requestUid and register the run so the main process emits its
// test/assertion/script events against an id the store recognises — this
// is what makes ctx.testResults / ctx.assertionResults populate.
const requestUid = uuid();
const requestItem = cloneDeep(item.draft || item);
requestItem.requestUid = requestUid;
dispatch(initRunRequestEvent({ requestUid, itemUid: item.uid, collectionUid: collection.uid }));
// Variable overrides: accept flat keys or { variables: {...} }.
const flatOverrides = overrides && typeof overrides === 'object' ? { ...overrides } : {};
const explicitVars = flatOverrides.variables;
delete flatOverrides.variables;
const mergedRuntime = {
...(collection.runtimeVariables || {}),
...flatOverrides,
...(explicitVars && typeof explicitVars === 'object' ? explicitVars : {})
};
const result = await sendNetworkRequest(requestItem, collection, environment, mergedRuntime);
// sendNetworkRequest resolves on network/request errors with `error` set —
// surface as a guest-side promise rejection rather than a fake success.
if (result?.error) {
const errorMessage = typeof result.error === 'string'
? result.error
: result.error?.message || 'Request failed';
push({ type: 'response', requestId, error: errorMessage });
return;
}
dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collection.uid,
response: {
status: result.status,
statusText: result.statusText,
headers: result.headers,
data: result.data,
dataBuffer: result.dataBuffer,
size: result.size,
duration: result.duration,
timeline: serializeTimeline(result.timeline)
}
})
);
push({ type: 'response', requestId, response: projectResponse(result) });
} catch (err) {
push({ type: 'response', requestId, error: err?.message || 'Request failed' });
}
},
[item, collection, environment, dispatch]
);
const handleGuestMessage = useCallback(
(data) => {
switch (data?.type) {
case 'ready':
break;
case 'sendRequest':
handleSendRequest(data.requestId, data.overrides);
break;
case 'setRuntimeVariable':
if (typeof data.key === 'string' && data.key.length) {
dispatch(appSetRuntimeVariable({ collectionUid: collection.uid, key: data.key, value: data.value }));
}
break;
case 'log':
console.log('[app]', ...(data.args || []));
break;
default:
break;
}
},
[handleSendRequest, dispatch, collection.uid]
);
const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage);
pushToGuestRef.current = pushToGuest;
// Push a full state snapshot on each readiness transition. Subsequent changes
// are handled by the granular effects below; using a ref avoids re-firing
// this effect (which would be a needless full re-broadcast).
const stateRef = useRef();
stateRef.current = { theme: displayedTheme, response, assertionResults, testResults, variables };
useEffect(() => {
if (!domReady) return;
pushToGuest({ type: 'state', ...stateRef.current });
}, [domReady, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'theme', theme: displayedTheme });
}, [displayedTheme, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'responseUpdate', response });
}, [response, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'results', assertionResults, testResults });
}, [assertionResults, testResults, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'variables', variables });
}, [variables, pushToGuest]);
const disableApp = useCallback(() => {
dispatch(toggleAppMode({ enabled: false, itemUid: item.uid, collectionUid: collection.uid }));
}, [dispatch, item.uid, collection.uid]);
return (
<StyledWrapper data-testid="app-view">
<div className="app-view-toolbar">
<span>App mode - {item.name}</span>
<button type="button" className="app-exit-btn" data-testid="app-exit-button" onClick={disableApp}>
Exit to editor
</button>
</div>
{code && code.trim().length ? (
<div className="app-webview-container">
<webview
ref={webviewRef}
src={src}
partition="persist:bruno-app-view"
webpreferences="disableDialogs=true, javascript=yes"
className="app-webview"
/>
</div>
) : (
<EmptyAppState
title="No app yet"
hint="Switch to the App tab on this request and write some HTML/JS to get started."
/>
)}
</StyledWrapper>
);
};
export default AppView;

View File

@@ -0,0 +1,200 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/*
* Shared transport for Bruno apps that run inside an Electron <webview>:
* host -> guest : webview.executeJavaScript(`window.__brunoReceive(<json>)`)
* guest -> host : console.log(SENTINEL + json), surfaced via 'console-message'
*
* Both the request-level AppView and the standalone CollectionApp use this — they
* differ only in the bootstrap script (which builds window.ctx) and the message
* handler the host registers.
*/
export const SENTINEL = '__BRUNO_APP_MSG__';
// JSON-encode for safe inlining into an executeJavaScript() string literal.
// U+2028/U+2029 are legal in JSON strings but illegal as raw JS source.
export const toJsArg = (value) =>
JSON.stringify(value === undefined ? null : value)
.replace(/</g, '\\u003c')
.replace(/[\u2028]/g, '\\u2028')
.replace(/[\u2029]/g, '\\u2029');
const FRAGMENT_STYLES = `<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
background: #ffffff;
color: #1e1e1e;
transition: background-color 0.15s, color 0.15s;
}
body.dark { background: #1e1e1e; color: #e0e0e0; }
</style>`;
/**
* Wrap user code into a guest document, injecting the host-supplied bootstrap
* script as early as possible (right after <head>) so window.ctx exists before
* any user script runs. Full HTML documents have the bootstrap injected; bare
* fragments are placed inside a minimal shell.
*/
export const wrapHtml = (bootstrap, userCode) => {
const code = userCode || '';
const isFullDocument = /<html[\s>]/i.test(code) || /<!doctype/i.test(code);
if (isFullDocument) {
if (/<head[^>]*>/i.test(code)) {
return code.replace(/<head[^>]*>/i, (m) => `${m}${bootstrap}`);
}
if (/<body[^>]*>/i.test(code)) {
return code.replace(/<body[^>]*>/i, (m) => `${m}${bootstrap}`);
}
return `${bootstrap}${code}`;
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${FRAGMENT_STYLES}
${bootstrap}
</head>
<body>
${code}
</body>
</html>`;
};
export const toDataUrl = (html) =>
`data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
export const serializeTimeline = (timeline) => {
if (!Array.isArray(timeline)) return timeline;
return timeline.map((entry) => ({
...entry,
timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp
}));
};
export const projectResponse = (r) => ({
status: r?.status ?? null,
statusText: r?.statusText ?? null,
data: r?.data ?? null,
headers: r?.headers ?? null,
duration: r?.duration ?? null,
size: r?.size ?? null
});
/**
* useAppWebview — manages an Electron <webview> guest and provides a typed
* messaging channel back to the host.
*
* const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage);
* …
* <webview ref={webviewRef} src={…} … />
*
* `webviewRef` is a **callback ref** (not an object ref). React invokes it with
* the element on mount and with `null` on unmount, which is the only way to
* reliably re-attach listeners when the <webview> is unmounted and remounted —
* e.g. when CollectionApp's user toggles between Code and Preview views. An
* object-ref + useEffect approach would not re-fire on remount because the ref
* object's identity is stable across mounts.
*
* pushToGuest({…}) is a no-op until the guest's dom-ready fires (and after a
* reload, until it fires again). Safe to call eagerly from effects.
*/
export const useAppWebview = (onGuestMessage) => {
const [domReady, setDomReady] = useState(false);
// Latest DOM element (for pushToGuest) and latest message handler (so the
// listener captures fresh state without needing to be re-bound).
const webviewElRef = useRef(null);
const onGuestMessageRef = useRef(onGuestMessage);
onGuestMessageRef.current = onGuestMessage;
// Outgoing messages sent before the guest is ready are queued and flushed by
// the dom-ready effect below. This is critical for guest scripts that call
// promise-returning ctx APIs (e.g. ctx.listRequests) at parse time — the host
// receives the request via console-message before Electron's `dom-ready`
// fires, and without a queue the reply gets dropped and the promise never
// resolves.
const pendingOutbox = useRef([]);
const sendToWebview = (webview, msg) => {
try {
webview.executeJavaScript(
`window.__brunoReceive && window.__brunoReceive(${toJsArg(msg)})`
).catch(() => {});
} catch (_) {
/* webview not yet attached */
}
};
const pushToGuest = useCallback(
(msg) => {
const webview = webviewElRef.current;
if (!webview || !domReady) {
pendingOutbox.current.push(msg);
return;
}
sendToWebview(webview, msg);
},
[domReady]
);
// Flush whatever piled up while the guest was still loading.
useEffect(() => {
if (!domReady) return;
const webview = webviewElRef.current;
if (!webview) return;
const queue = pendingOutbox.current;
if (!queue.length) return;
pendingOutbox.current = [];
for (const msg of queue) sendToWebview(webview, msg);
}, [domReady]);
// Stable callback ref. We stash the per-element listener bag on the element
// itself so we can clean up exactly the right listeners on unmount or replace.
const webviewRef = useCallback((element) => {
const prev = webviewElRef.current;
if (prev && prev !== element) {
const h = prev.__brunoHandlers;
if (h) {
prev.removeEventListener('console-message', h.onConsoleMessage);
prev.removeEventListener('dom-ready', h.onDomReady);
prev.removeEventListener('did-start-loading', h.onStartLoading);
prev.__brunoHandlers = null;
}
}
// Queued messages belong to the prior guest; drop them on element replace.
pendingOutbox.current = [];
webviewElRef.current = element || null;
// dom-ready will fire fresh on the new element; until then pushToGuest no-ops.
setDomReady(false);
if (!element) return;
const onConsoleMessage = (e) => {
const text = e?.message;
if (typeof text !== 'string' || !text.startsWith(SENTINEL)) return;
try {
onGuestMessageRef.current(JSON.parse(text.slice(SENTINEL.length)));
} catch (_) {
/* not our message */
}
};
const onDomReady = () => setDomReady(true);
// A reload (code edit) tears down the guest; reset readiness so the next
// dom-ready can flip us back to true.
const onStartLoading = () => setDomReady(false);
element.__brunoHandlers = { onConsoleMessage, onDomReady, onStartLoading };
element.addEventListener('console-message', onConsoleMessage);
element.addEventListener('dom-ready', onDomReady);
element.addEventListener('did-start-loading', onStartLoading);
}, []);
return { domReady, pushToGuest, webviewRef };
};

View File

@@ -0,0 +1,7 @@
# What's New in Bruno
- Various stability and performance improvements.
---
For the full release history, see the [Bruno releases page](https://github.com/usebruno/bruno/releases).

View File

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

View File

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

View File

@@ -45,6 +45,15 @@ const StyledWrapper = styled.div`
text-decoration: underline;
}
.cm-ghost-text-ai {
opacity: 0.45;
color: ${(props) => props.theme.colors.text.muted};
font-style: italic;
pointer-events: none;
user-select: none;
white-space: pre;
}
/* Removes the glow outline around the folded json */
.CodeMirror-foldmarker {
text-shadow: none;
@@ -165,6 +174,32 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.codemirror.searchLineHighlightCurrent};
}
@keyframes cm-error-line-flash {
0%, 60% {
background-color: ${(props) => props.theme.status.danger.background};
}
100% {
background-color: transparent;
}
}
.CodeMirror .cm-error-line-flash {
background-color: transparent;
animation: cm-error-line-flash 3s ease-in-out;
}
.CodeMirror .cm-error-line-flash-gutter {
color: ${(props) => props.theme.colors.text.danger} !important;
font-weight: 600;
}
@media (prefers-reduced-motion: reduce) {
.CodeMirror .cm-error-line-flash {
animation: none;
background-color: ${(props) => props.theme.status.danger.background};
}
}
.cm-search-match {
background: rgba(255, 193, 7, 0.25);
}

View File

@@ -6,9 +6,12 @@
*/
import React, { createRef } from 'react';
import { useSelector } from 'react-redux';
import { debounce, isEqual } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import { setupAiAutocomplete } from 'utils/codemirror/aiGhostText';
import { buildAutocompleteContext } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
@@ -16,6 +19,7 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import { setupCodeMirrorResizeRefresh } from 'utils/codemirror/resize';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
import {
applyEditorState,
@@ -64,13 +68,16 @@ class CodeEditor extends React.Component {
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
const runShortcut = () => {
if (this.props.onRun) {
this.props.onRun();
return;
}
return CodeMirror.Pass;
};
/**
* No-op. We claim Cmd-Enter / Ctrl-Enter here only to suppress CodeMirror's
* sublime keymap default (insertLineAfter), which would otherwise insert a
* newline. sendRequest dispatch is owned by Mousetrap — the editor input has
* the `mousetrap` class (added below) so the global
* useKeybinding('sendRequest', …) in RequestTabPanel handles it, and only
* in request tabs. Falling through with CodeMirror.Pass when onRun is absent
* would re-introduce the newline in collection/folder-level editors.
*/
const runShortcut = () => {};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
@@ -256,6 +263,24 @@ class CodeEditor extends React.Component {
autoCompleteOptions
);
// AI ghost-text autocomplete (script editors only). Stays inert until
// the user has both enabled AI and configured a provider.
if (this.props.scriptType) {
this.aiAutocompleteCleanup = setupAiAutocomplete(editor, {
scriptType: this.props.scriptType,
isEnabled: () => {
const ai = this.props.aiPreferences;
return Boolean(ai?.enabled) && ai?.autocomplete?.enabled !== false;
},
getTriggerMode: () => this.props.aiPreferences?.autocomplete?.triggerMode || 'debounced',
getContext: () => buildAutocompleteContext({
item: this.props.item,
collection: this.props.collection,
scriptType: this.props.scriptType
})
});
}
setupLinkAware(editor);
// Setup lint error tooltip on line number hover
@@ -266,6 +291,8 @@ class CodeEditor extends React.Component {
if (cmInput) {
cmInput.classList.add('mousetrap');
}
this.cleanupResizeRefresh = setupCodeMirrorResizeRefresh(editor, this._node);
}
}
@@ -386,6 +413,7 @@ class CodeEditor extends React.Component {
});
}
this.aiAutocompleteCleanup?.();
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
@@ -399,6 +427,7 @@ class CodeEditor extends React.Component {
// Clean up lint error tooltip
this.cleanupLintErrorTooltip?.();
this.cleanupResizeRefresh?.();
const wrapper = this.editor.getWrapperElement();
wrapper?.parentNode?.removeChild(wrapper);
@@ -463,7 +492,15 @@ class CodeEditor extends React.Component {
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
const persistenceScope = usePersistenceScope();
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
const aiPreferences = useSelector((state) => state.app.preferences?.ai);
return (
<CodeEditor
{...props}
persistenceScope={persistenceScope}
aiPreferences={aiPreferences}
ref={ref}
/>
);
});
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';

View File

@@ -0,0 +1,84 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 0.5rem;
.app-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem 0.5rem;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.app-toolbar .view-toggle {
display: flex;
align-items: center;
height: 24px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.base};
overflow: hidden;
}
.app-toolbar .view-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
height: 100%;
border: none;
border-right: 1px solid ${(props) => props.theme.input.border};
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
font-size: 11px;
&:last-child { border-right: none; }
&:hover:not(.active) {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&.active {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.primary.text};
}
}
.app-pane {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.app-pane.code div.CodeMirror {
height: 100%;
}
.app-webview-container {
flex: 1 1 0;
min-height: 0;
display: flex;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
overflow: hidden;
background: ${(props) => props.theme.background.surface0};
}
.app-webview {
width: 100%;
height: 100%;
flex: 1 1 0;
border: 0;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,396 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { sendNetworkRequest } from 'utils/network/index';
import {
findEnvironmentInCollection,
findItemInCollectionByPathname,
flattenItems,
getEnvironmentVariables,
getGlobalEnvironmentVariables,
isItemARequest
} from 'utils/collections';
import { uuid } from 'utils/common';
import {
appSetRuntimeVariable,
initRunRequestEvent,
responseReceived,
updateAppCode
} from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload, buildDocsContextFromCollection } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import EmptyAppState from '../AppView/EmptyAppState';
import {
SENTINEL,
wrapHtml,
toDataUrl,
serializeTimeline,
projectResponse,
useAppWebview
} from '../AppView/webview-bridge';
/*
* Standalone collection-/folder-level app — a file (.bru/.yml) of type 'app'
* that lives in the sidebar and opens as its own tab. The user toggles between
* Code (CodeEditor) and Preview (sandboxed <webview>); preview re-runs whenever
* the code prop changes.
*
* Collection ctx surface differs from the request-level AppView:
* shared: theme, log, variables, setRuntimeVariable, onThemeChange, onVariablesUpdate
* added: collection, listRequests(), runRequest(pathname, overrides?)
* dropped: sendRequest, response, assertionResults, testResults
* (and their on* hooks — they only make sense for one request)
*/
const COLLECTION_CTX_BOOTSTRAP = `<script>
(function () {
if (window.__brunoBootstrapped) return;
window.__brunoBootstrapped = true;
var SENTINEL = ${JSON.stringify(SENTINEL)};
var pending = new Map();
var nextReplyId = 0;
function sendToHost(payload) {
try { console.log(SENTINEL + JSON.stringify(payload)); } catch (e) {}
}
function awaitReply(type, extra) {
return new Promise(function (resolve, reject) {
var replyId = ++nextReplyId;
pending.set(replyId, { resolve: resolve, reject: reject });
sendToHost(Object.assign({ type: type, replyId: replyId }, extra || {}));
});
}
var ctx = {
theme: 'light',
variables: {},
collection: null,
onThemeChange: null,
onVariablesUpdate: null,
listRequests: function () {
return awaitReply('listRequests');
},
runRequest: function (pathname, overrides) {
return awaitReply('runRequest', { pathname: String(pathname || ''), overrides: overrides || {} });
},
setRuntimeVariable: function (key, value) {
sendToHost({ type: 'setRuntimeVariable', key: String(key), value: value });
},
log: function () {
var args = Array.prototype.slice.call(arguments);
sendToHost({ type: 'log', args: args });
}
};
window.ctx = ctx;
function applyTheme(theme) {
ctx.theme = theme || 'light';
if (document.body) {
document.body.classList.remove('light', 'dark');
document.body.classList.add(ctx.theme);
}
}
window.__brunoReceive = function (msg) {
if (!msg) return;
switch (msg.type) {
case 'state':
applyTheme(msg.theme);
ctx.variables = msg.variables || {};
ctx.collection = msg.collection || null;
break;
case 'theme':
applyTheme(msg.theme);
if (typeof ctx.onThemeChange === 'function') ctx.onThemeChange(ctx.theme);
break;
case 'variables':
ctx.variables = msg.variables || {};
if (typeof ctx.onVariablesUpdate === 'function') ctx.onVariablesUpdate(ctx.variables);
break;
case 'collection':
ctx.collection = msg.collection || null;
break;
case 'reply': {
var entry = pending.get(msg.replyId);
if (!entry) return;
pending.delete(msg.replyId);
if (msg.error) entry.reject(new Error(msg.error));
else entry.resolve(msg.result);
break;
}
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { sendToHost({ type: 'ready' }); });
} else {
sendToHost({ type: 'ready' });
}
})();
</script>`;
const buildVariables = (collection) => {
const env = getEnvironmentVariables(collection);
const global = getGlobalEnvironmentVariables({
globalEnvironments: collection?.globalEnvironments || [],
activeGlobalEnvironmentUid: collection?.activeGlobalEnvironmentUid
});
return {
...global,
...env,
...(collection?.collectionVariables || {}),
...(collection?.runtimeVariables || {})
};
};
const listRequestSummaries = (collection) =>
flattenItems(collection?.items || [])
.filter(isItemARequest)
.map((it) => ({
uid: it.uid,
name: it.name,
pathname: it.pathname,
type: it.type,
method: it.request?.method || null,
url: it.request?.url || null
}));
const CollectionApp = ({ item, collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [view, setView] = useState('preview');
const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
// Preview HTML is keyed on the *saved* code so typing doesn't reload the guest
// on every keystroke. The user toggles to Preview after saving to see updates.
const src = useMemo(
() => toDataUrl(wrapHtml(COLLECTION_CTX_BOOTSTRAP, code || '')),
[code]
);
const environment = useMemo(
() => findEnvironmentInCollection(collection, collection.activeEnvironmentUid),
[collection]
);
const variables = useMemo(() => buildVariables(collection), [collection]);
const collectionInfo = useMemo(
() => ({ name: collection?.name || null, pathname: collection?.pathname || null }),
[collection?.name, collection?.pathname]
);
const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]);
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
const onEdit = useCallback(
(value) => dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid })),
[dispatch, item.uid, collection.uid]
);
const onSave = useCallback(
() => dispatch(saveRequest(item.uid, collection.uid)),
[dispatch, item.uid, collection.uid]
);
// Execute a single request by its pathname (returned earlier from listRequests).
// Mirrors AppView.handleSendRequest: mints a requestUid, registers the run, merges
// overrides into runtime variables, sends, and dispatches responseReceived so the
// request's normal Response pane updates too.
const runRequestByPath = useCallback(
async (pathname, overrides) => {
const target = findItemInCollectionByPathname(collection, pathname);
if (!target) {
throw new Error(`Request not found: ${pathname}`);
}
if (!isItemARequest(target)) {
throw new Error(`Item is not a request: ${pathname}`);
}
const requestUid = uuid();
const requestItem = cloneDeep(target.draft || target);
requestItem.requestUid = requestUid;
dispatch(
initRunRequestEvent({ requestUid, itemUid: target.uid, collectionUid: collection.uid })
);
const flat = overrides && typeof overrides === 'object' ? { ...overrides } : {};
const explicit = flat.variables;
delete flat.variables;
const mergedRuntime = {
...(collection.runtimeVariables || {}),
...flat,
...(explicit && typeof explicit === 'object' ? explicit : {})
};
const result = await sendNetworkRequest(requestItem, collection, environment, mergedRuntime);
if (result?.error) {
const errorMessage = typeof result.error === 'string'
? result.error
: result.error?.message || 'Request failed';
throw new Error(errorMessage);
}
dispatch(
responseReceived({
itemUid: target.uid,
collectionUid: collection.uid,
response: {
status: result.status,
statusText: result.statusText,
headers: result.headers,
data: result.data,
dataBuffer: result.dataBuffer,
size: result.size,
duration: result.duration,
timeline: serializeTimeline(result.timeline)
}
})
);
return projectResponse(result);
},
[collection, environment, dispatch]
);
// pushToGuest is produced by useAppWebview, which itself needs handleGuestMessage —
// so we can't put it in handleGuestMessage's useCallback deps (circular). Instead
// route guest replies through a ref that always points at the latest pushToGuest.
// Without this, the callback closes over the first-render pushToGuest (which is a
// no-op until dom-ready) and reply messages never reach the guest.
const pushToGuestRef = useRef(() => {});
const handleGuestMessage = useCallback(
async (data) => {
const push = pushToGuestRef.current;
switch (data?.type) {
case 'ready':
break;
case 'log':
console.log('[app]', ...(data.args || []));
break;
case 'setRuntimeVariable':
if (typeof data.key === 'string' && data.key.length) {
dispatch(
appSetRuntimeVariable({ collectionUid: collection.uid, key: data.key, value: data.value })
);
}
break;
case 'listRequests': {
push({ type: 'reply', replyId: data.replyId, result: listRequestSummaries(collection) });
break;
}
case 'runRequest': {
try {
const res = await runRequestByPath(data.pathname, data.overrides);
push({ type: 'reply', replyId: data.replyId, result: res });
} catch (err) {
push({ type: 'reply', replyId: data.replyId, error: err?.message || 'runRequest failed' });
}
break;
}
default:
break;
}
},
[dispatch, collection, runRequestByPath]
);
const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage);
pushToGuestRef.current = pushToGuest;
const stateRef = useRef();
stateRef.current = { theme: displayedTheme, variables, collection: collectionInfo };
useEffect(() => {
if (!domReady) return;
pushToGuest({ type: 'state', ...stateRef.current });
}, [domReady, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'theme', theme: displayedTheme });
}, [displayedTheme, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'variables', variables });
}, [variables, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'collection', collection: collectionInfo });
}, [collectionInfo, pushToGuest]);
return (
<StyledWrapper data-testid="collection-app">
<div className="app-toolbar">
<span>App - {item.name}</span>
<div className="view-toggle" data-testid="collection-app-view-toggle">
<button
type="button"
data-testid="collection-app-view-code"
className={classnames('view-btn', { active: view === 'code' })}
onClick={() => setView('code')}
>
Code
</button>
<button
type="button"
data-testid="collection-app-view-preview"
className={classnames('view-btn', { active: view === 'preview' })}
onClick={() => setView('preview')}
>
Preview
</button>
</div>
</div>
{view === 'code' ? (
<div className="app-pane code relative" data-testid="collection-app-code">
<CodeEditor
collection={collection}
value={code || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
onSave={onSave}
mode="htmlmixed"
/>
<AIAssist
scriptType="app-collection"
currentScript={code || ''}
docsContext={docsContext}
variables={aiVariables}
onApply={onEdit}
/>
</div>
) : code && code.trim().length ? (
<div className="app-pane app-webview-container" data-testid="collection-app-preview">
<webview
ref={webviewRef}
src={src}
partition="persist:bruno-app-view"
webpreferences="disableDialogs=true, javascript=yes"
className="app-webview"
/>
</div>
) : (
<div className="app-pane" data-testid="collection-app-preview">
<EmptyAppState
title="No app yet"
hint="Switch to Code and write some HTML/JS"
/>
</div>
)}
</StyledWrapper>
);
};
export default CollectionApp;

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

@@ -4,11 +4,13 @@ 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 { useRef } from 'react';
import { useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload, buildDocsContextFromCollection } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
import Button from 'ui/Button/index';
@@ -25,6 +27,8 @@ const Docs = ({ collection }) => {
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);
const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]);
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
// StyledWrapper has overflow-y: auto — use null selector.
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
@@ -85,18 +89,21 @@ const Docs = ({ collection }) => {
</div>
</div>
{isEditing ? (
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
<div className="relative flex-1 min-h-0">
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} variables={aiVariables} onApply={onEdit} />
</div>
) : (
<div className="pl-1">
<div className="h-[1px] min-h-[500px]">

View File

@@ -8,6 +8,7 @@ import ShareCollection from 'components/ShareCollection/index';
import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import StyledWrapper from './StyledWrapper';
import Migration from '../Migration';
const Info = ({ collection }) => {
const dispatch = useDispatch();
@@ -126,6 +127,8 @@ const Info = ({ collection }) => {
</div>
</div>
{showGenerateDocumentationModal && <GenerateDocumentation collectionUid={collection.uid} onClose={() => setShowGenerateDocumentationModal(false)} />}
<Migration collection={collection} />
</div>
</div>
</StyledWrapper>

View File

@@ -0,0 +1,40 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.backup-section {
border: 1px solid ${(props) => props.theme.border.border2};
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => props.theme.background.mantle};
padding: 12px 14px;
}
.backup-section-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: ${(props) => props.theme.text};
}
.backup-section-title {
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
text-transform: uppercase;
letter-spacing: 0.04em;
}
.backup-section-help {
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.45;
margin: 0 0 10px 0;
}
.backup-section-action {
display: flex;
justify-content: flex-start;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import { migrateCollectionToYml } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
const MigrateToYmlModal = ({ collection, onClose }) => {
const dispatch = useDispatch();
const [isMigrating, setIsMigrating] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const handleMigrate = () => {
setIsMigrating(true);
dispatch(migrateCollectionToYml(collection.uid))
.catch(() => {})
.finally(() => {
setIsMigrating(false);
onClose();
});
};
const handleExportBackup = async () => {
if (isExporting) return;
setIsExporting(true);
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name);
if (result?.success) {
toast.success('Collection backup exported');
}
} catch (error) {
toast.error('Failed to export backup: ' + error.message);
} finally {
setIsExporting(false);
}
};
return (
<Portal>
<StyledWrapper>
<Modal
size="md"
title="Migrate to YML format"
confirmText="Migrate"
confirmDisabled={isExporting || isMigrating}
handleConfirm={handleMigrate}
handleCancel={onClose}
>
<div>
<p>
This will convert all files in <strong>{collection.name}</strong> from <code>.bru</code> format to <code>.yml</code> format.
</p>
<div className="mt-4 text-sm text-muted">
<p className="font-medium mb-2">What will happen:</p>
<ul className="list-disc ml-5 flex flex-col gap-1">
<li>All <code>.bru</code> request files will be converted to <code>.yml</code></li>
<li>Environment files will be converted to YML format</li>
<li><code>bruno.json</code> will be replaced with <code>opencollection.yml</code></li>
<li>The collection will be reloaded after migration</li>
</ul>
</div>
<div className="backup-section mt-4">
<div className="backup-section-head">
<span className="backup-section-title">Backup</span>
</div>
<p className="backup-section-help">
Export this collection as a ZIP archive before migrating, in case you want to restore it later.
</p>
<div className="backup-section-action">
<Button
data-testid="export-collection-backup-button"
size="sm"
color="secondary"
variant="outline"
onClick={handleExportBackup}
disabled={isExporting}
>
{isExporting ? 'Exporting…' : 'Export Collection'}
</Button>
</div>
</div>
</div>
</Modal>
</StyledWrapper>
</Portal>
);
};
export default MigrateToYmlModal;

View File

@@ -0,0 +1,20 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.migration-section {
padding-top: 1.5rem;
margin-top: 1.5rem;
}
.icon-box.migration {
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.08)};
border: 1px solid ${(props) => rgba(props.theme.colors.text.yellow, 0.09)};
svg {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { IconFileCode, IconTransform } from '@tabler/icons';
import Button from 'ui/Button';
import MigrateToYmlModal from './MigrateToYmlModal';
import StyledWrapper from './StyledWrapper';
const Migration = ({ collection }) => {
const [showConfirmModal, setShowConfirmModal] = useState(false);
// Only show for bru format collections
if (collection.format !== 'bru') {
return null;
}
return (
<StyledWrapper>
<div className="migration-section">
<div className="text-lg font-medium flex items-center gap-2 mb-4">
<IconTransform size={20} stroke={1.5} />
Migration
</div>
<div className="flex items-start">
<div className="icon-box migration flex-shrink-0 p-3 rounded-lg">
<IconFileCode className="w-5 h-5" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-medium">Migrate to YML file format</div>
<div className="my-1 text-muted text-sm">
This collection is stored in BRU format.{' '}
Switch to YML.{' '}
<a
href="https://blog.usebruno.com/making-yaml-the-default-in-bruno-v3.1"
target="_blank"
rel="noopener noreferrer"
className="text-link hover:underline"
>
Learn More &#x2197;
</a>
</div>
<Button
data-testid="migrate-collection-to-yml-button"
size="sm"
color="primary"
className="mt-2"
onClick={() => setShowConfirmModal(true)}
>
Convert to YML
</Button>
</div>
</div>
</div>
{showConfirmModal && (
<MigrateToYmlModal
collection={collection}
onClose={() => setShowConfirmModal(false)}
/>
)}
</StyledWrapper>
);
};
export default Migration;

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

@@ -1,8 +1,10 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload } from 'utils/ai';
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -13,6 +15,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Script = ({ collection }) => {
const dispatch = useDispatch();
@@ -59,6 +62,20 @@ const Script = ({ collection }) => {
return () => clearTimeout(timer);
}, [activeTab]);
useFocusErrorLine({
uid: collection.uid,
editorRef: preRequestEditorRef,
scriptPhase: 'pre-request',
isVisible: activeTab === 'pre-request'
});
useFocusErrorLine({
uid: collection.uid,
editorRef: postResponseEditorRef,
scriptPhase: 'post-response',
isVisible: activeTab === 'post-response'
});
const onRequestScriptEdit = (value) => {
dispatch(
updateCollectionRequestScript({
@@ -85,6 +102,8 @@ const Script = ({ collection }) => {
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -108,39 +127,57 @@ const Script = ({ collection }) => {
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
scriptType="pre-request"
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
variables={aiVariables}
onApply={onRequestScriptEdit}
/>
</div>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
scriptType="post-response"
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
variables={aiVariables}
onApply={onResponseScriptEdit}
/>
</div>
</TabsContent>
</Tabs>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ const StyledWrapper = styled.div`
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0; /* Important for proper flex behavior */
min-height: 0;
}
.network-empty {
@@ -68,66 +68,126 @@ const StyledWrapper = styled.div`
flex-direction: column;
height: 100%;
overflow: hidden;
min-height: 0; /* Important for proper flex behavior */
min-height: 0;
position: relative;
&.is-resizing {
cursor: col-resize;
user-select: none;
}
}
.requests-header {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 4px 16px;
flex-shrink: 0;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 10px;
color: ${(props) => props.theme.console.titleColor};
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
& > * {
min-width: 0;
overflow: hidden;
}
.header-cell {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
&:first-child { padding-left: 16px; }
&:last-child { padding-right: 16px; }
&:hover {
color: ${(props) => props.theme.console.messageColor};
}
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
svg { flex-shrink: 0; }
}
}
.requests-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0; /* Important for proper scrolling */
min-height: 0;
}
.request-row {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 2px 16px;
cursor: pointer;
transition: background-color 0.1s ease;
font-size: ${(props) => props.theme.font.size.sm};
align-items: center;
&:hover {
background: ${(props) => props.theme.console.logHoverBg};
& > * {
min-width: 0;
overflow: hidden;
}
&:hover { background: ${(props) => props.theme.console.logHoverBg}; }
&.selected {
padding-left: 13px;
background: ${(props) => props.theme.console.logHoverBg};
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor};
}
}
.col-separator {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
transform: translateX(-2px);
cursor: col-resize;
z-index: 3;
&::after {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 1px;
height: 100%;
background: ${(props) => props.theme.sidebar.dragbar.border};
}
&:hover::after,
&.resizing::after {
background: ${(props) => props.theme.sidebar.dragbar.activeBorder};
}
}
.request-method { padding: 2px 8px 2px 16px; }
.request-status { padding: 2px 8px; }
.method-badge {
display: inline-flex;
align-items: center;
justify-content: start;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 45px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
font-size: ${(props) => props.theme.font.size.sm};
}
.status-badge { font-size: ${(props) => props.theme.font.size.sm}; }
.request-domain {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
@@ -135,6 +195,7 @@ const StyledWrapper = styled.div`
}
.request-path {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
@@ -143,23 +204,37 @@ const StyledWrapper = styled.div`
}
.request-time {
padding: 2px 8px;
color: ${(props) => props.theme.console.timestampColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-duration {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-right { text-align: right; }
.request-size {
padding: 2px 8px;
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;

View File

@@ -1,12 +1,27 @@
import React, { useMemo } from 'react';
import { usePersistedState } from 'hooks/usePersistedState';
import { useSelector, useDispatch } from 'react-redux';
import {
IconNetwork
IconNetwork,
IconArrowUp,
IconArrowDown
} from '@tabler/icons';
import {
setSelectedRequest
} from 'providers/ReduxStore/slices/logs';
import { useResizableColumns } from 'hooks/useResizableColumns';
import StyledWrapper from './StyledWrapper';
import { sortRequests } from './utils';
const COLUMNS = [
{ key: 'method', label: 'Method', width: 80, align: 'left' },
{ key: 'status', label: 'Status', width: 70, align: 'left' },
{ key: 'domain', label: 'Domain', width: 180, align: 'left' },
{ key: 'path', label: 'Path', width: 300, align: 'left' },
{ key: 'time', label: 'Time', width: 110, align: 'left' },
{ key: 'duration', label: 'Duration', width: 100, align: 'right' },
{ key: 'size', label: 'Size', width: 80, align: 'right' }
];
const MethodBadge = ({ method }) => {
const methodLower = method?.toLowerCase() || 'get';
@@ -28,7 +43,7 @@ const StatusBadge = ({ status, statusCode }) => {
);
};
const RequestRow = ({ request, isSelected, onClick }) => {
const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
const { data } = request;
const { request: req, response: res, timestamp } = data;
@@ -82,6 +97,9 @@ const RequestRow = ({ request, isSelected, onClick }) => {
<div
className={`request-row ${isSelected ? 'selected' : ''}`}
onClick={onClick}
style={{ gridTemplateColumns }}
data-testid="network-request-row"
>
<div className="request-method">
<MethodBadge method={req?.method} />
@@ -116,12 +134,27 @@ const RequestRow = ({ request, isSelected, onClick }) => {
const NetworkTab = () => {
const dispatch = useDispatch();
const [sortConfig, setSortConfig] = usePersistedState({ key: 'devtools-network-sort', default: { key: null, direction: null } });
const [savedColWidths, setSavedColWidths] = usePersistedState({ key: 'devtools-network-col-widths', default: null });
const {
containerRef,
gridTemplateColumns,
separatorPositions,
resizingIdx,
handleResizeStart
} = useResizableColumns({
defaultWidths: COLUMNS.map((c) => c.width),
initialWidths: savedColWidths,
minColWidth: 60,
onResizeEnd: setSavedColWidths
});
const { networkFilters, selectedRequest } = useSelector((state) => state.logs);
const collections = useSelector((state) => state.collections.collections);
const allRequests = useMemo(() => {
const requests = [];
collections.forEach((collection) => {
if (collection.timeline) {
collection.timeline
@@ -135,7 +168,6 @@ const NetworkTab = () => {
});
}
});
return requests.sort((a, b) => a.timestamp - b.timestamp);
}, [collections]);
@@ -146,10 +178,21 @@ const NetworkTab = () => {
});
}, [allRequests, networkFilters]);
const handleRequestClick = (request) => {
dispatch(setSelectedRequest(request));
const handleRequestClick = (request) => dispatch(setSelectedRequest(request));
const handleHeaderClick = (key) => {
setSortConfig((prev) => {
if (prev.key !== key) return { key, direction: 'asc' };
if (prev.direction === 'asc') return { key, direction: 'desc' };
return { key: null, direction: null };
});
};
const sortedRequests = useMemo(
() => sortRequests(filteredRequests, sortConfig.key, sortConfig.direction),
[filteredRequests, sortConfig]
);
return (
<StyledWrapper>
<div className="network-content">
@@ -160,27 +203,49 @@ const NetworkTab = () => {
<span>Requests will appear here as you make API calls</span>
</div>
) : (
<div className="requests-container">
<div className="requests-header">
<div>Method</div>
<div>Status</div>
<div>Domain</div>
<div>Path</div>
<div>Time</div>
<div className="text-right">Duration</div>
<div className="text-right">Size</div>
<div className={`requests-container${resizingIdx !== null ? ' is-resizing' : ''}`}>
<div className="requests-header" style={{ gridTemplateColumns }}>
{COLUMNS.map((col) => (
<div
key={col.key}
className={`header-cell${col.align === 'right' ? ' text-right' : ''}`}
onClick={() => handleHeaderClick(col.key)}
data-testid={`network-header-${col.key}`}
>
<span title={col.label}>{col.label}</span>
{sortConfig.key === col.key && (
sortConfig.direction === 'asc'
? <IconArrowUp size={14} strokeWidth={2} data-testid="sort-icon-asc" />
: <IconArrowDown size={14} strokeWidth={2} data-testid="sort-icon-desc" />
)}
</div>
))}
</div>
<div className="requests-list">
{filteredRequests.map((request, index) => (
<div ref={containerRef} className="requests-list">
{sortedRequests.map((request, index) => (
<RequestRow
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
request={request}
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
isSelected={
selectedRequest?.timestamp === request.timestamp
&& selectedRequest?.itemUid === request.itemUid
}
onClick={() => handleRequestClick(request)}
gridTemplateColumns={gridTemplateColumns}
/>
))}
</div>
{separatorPositions.map((left, i) => (
<div
key={i}
className={`col-separator${resizingIdx === i ? ' resizing' : ''}`}
style={{ left }}
onMouseDown={(e) => handleResizeStart(e, i)}
data-testid={`network-col-separator-${i}`}
/>
))}
</div>
)}
</div>

View File

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

View File

@@ -0,0 +1,31 @@
export const getSortValue = (request, key) => {
const { request: req, response: res, timestamp } = request.data;
switch (key) {
case 'method': return req?.method?.toUpperCase() ?? '';
case 'status': return res?.statusCode || res?.status || 0;
case 'domain': {
try { return new URL(req?.url || '').hostname; } catch { return req?.url || ''; }
}
case 'path': {
try {
const u = new URL(req?.url || '');
return u.pathname + u.search;
} catch { return req?.url || ''; }
}
case 'time': return timestamp || 0;
case 'duration': return res?.duration || 0;
case 'size': return res?.size || 0;
default: return '';
}
};
export const sortRequests = (requests, key, direction) => {
if (!key || !direction) return requests;
return [...requests].sort((a, b) => {
const valueA = getSortValue(a, key);
const valueB = getSortValue(b, key);
if (valueA < valueB) return direction === 'asc' ? -1 : 1;
if (valueA > valueB) return direction === 'asc' ? 1 : -1;
return 0;
});
};

View File

@@ -4,11 +4,8 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: ${(props) => props.theme.console.contentBg};
border-left: 1px solid ${(props) => props.theme.console.border};
min-width: 400px;
max-width: 600px;
width: 40%;
overflow: hidden;
.panel-header {
@@ -317,6 +314,7 @@ const StyledWrapper = styled.div`
height: 100% !important;
max-height: 400px !important;
padding: 0.5rem !important;
overflow: auto !important;
.network-logs-pre {
color: ${(props) => props.theme.console.messageColor} !important;

View File

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

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { usePersistedState } from 'hooks/usePersistedState';
import { useSelector, useDispatch } from 'react-redux';
import ReactJson from 'react-json-view';
import { useTheme } from 'providers/Theme';
@@ -33,6 +34,10 @@ import RequestDetailsPanel from './RequestDetailsPanel';
import ErrorDetailsPanel from './ErrorDetailsPanel';
import Performance from '../Performance';
import StyledWrapper from './StyledWrapper';
import { useResizablePanel } from 'hooks/useResizablePanel';
const MIN_DETAILS_PANEL_WIDTH = 280;
const MAX_DETAILS_PANEL_WIDTH = 800;
const LogIcon = ({ type }) => {
const iconProps = { size: 16, strokeWidth: 1.5 };
@@ -381,8 +386,17 @@ const Console = () => {
const dispatch = useDispatch();
const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector((state) => state.logs);
const collections = useSelector((state) => state.collections.collections);
const [savedDetailsPanelWidth, setSavedDetailsPanelWidth] = usePersistedState({ key: 'devtools-details-panel-width', default: 400 });
const consoleRef = useRef(null);
const { width: detailsPanelWidth, handleDragStart: handleDetailsPanelDragStart } = useResizablePanel({
initialWidth: savedDetailsPanelWidth,
minWidth: MIN_DETAILS_PANEL_WIDTH,
maxWidth: MAX_DETAILS_PANEL_WIDTH,
direction: 'right',
onResizeEnd: (newWidth) => setSavedDetailsPanelWidth(newWidth)
});
const logCounts = logs.reduce((counts, log) => {
counts[log.type] = (counts[log.type] || 0) + 1;
return counts;
@@ -614,7 +628,16 @@ const Console = () => {
<div className="network-main">
{renderTabContent()}
</div>
<RequestDetailsPanel />
<div className="details-panel-wrapper" style={{ width: detailsPanelWidth }}>
<div
className="details-drag-handle"
onMouseDown={handleDetailsPanelDragStart}
data-testid="details-panel-drag-handle"
>
<div className="drag-request-border" />
</div>
<RequestDetailsPanel />
</div>
</div>
) : activeTab === 'debug' && selectedError ? (
<div className="debug-with-details">

View File

@@ -4,11 +4,13 @@ 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 { useRef } from 'react';
import { useMemo, 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 AIAssist from 'components/AIAssist';
import { buildAiContextPayload } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
@@ -42,6 +44,10 @@ const Documentation = ({ item, collection }) => {
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const { requestContext, variables: aiVariables } = useMemo(
() => buildAiContextPayload(item, collection),
[item, collection]
);
if (!item) {
return null;
@@ -54,18 +60,27 @@ const Documentation = ({ item, collection }) => {
</div>
{isEditing ? (
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
<div className="relative flex-1 min-h-0">
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
<AIAssist
scriptType="docs"
currentScript={docs || ''}
requestContext={requestContext}
variables={aiVariables}
onApply={onEdit}
/>
</div>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
)}

View File

@@ -21,17 +21,19 @@ const findScrollParent = (element) => {
const TableRow = React.memo(
({ children, item, context, ...rest }) => {
const rowIndex = Number(rest['data-item-index']);
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave, keyColumn } = context;
const isEmpty = isLastEmptyRow(item, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
const isDragOver = canDrag && dragOverRow === rowIndex;
const existingClass = rest.className || '';
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
const rowName = keyColumn ? item?.[keyColumn.key] : undefined;
return (
<tr
{...rest}
className={className}
data-row-name={rowName || undefined}
draggable={canDrag}
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
@@ -168,6 +170,17 @@ const EditableTable = ({
};
}, [defaultRow, checkboxKey]);
const hasAnyValue = useCallback((row) => {
for (const col of columns) {
const val = col.getValue ? col.getValue(row) : row[col.key];
const defaultVal = defaultRow[col.key];
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
return true;
}
}
return false;
}, [columns, defaultRow]);
const rowsWithEmpty = useMemo(() => {
if (!showAddRow) {
return rows;
@@ -177,16 +190,11 @@ const EditableTable = ({
return [createEmptyRow()];
}
const lastRow = rows[rows.length - 1];
const keyColumn = columns.find((col) => col.isKeyField);
if (keyColumn) {
const lastRowKeyValue = keyColumn.getValue ? keyColumn.getValue(lastRow) : lastRow[keyColumn.key];
const isLastRowEmpty = !lastRowKeyValue || (typeof lastRowKeyValue === 'string' && lastRowKeyValue.trim() === '');
if (isLastRowEmpty) {
return rows;
}
// If the last row is already empty (e.g. a stray empty row loaded from a
// pre-existing file), don't append another one — otherwise the table would
// render two empty rows at the bottom on the initial render.
if (!hasAnyValue(rows[rows.length - 1])) {
return rows;
}
if (!emptyRowUidRef.current || rows.some((r) => r.uid === emptyRowUidRef.current)) {
@@ -198,15 +206,11 @@ const EditableTable = ({
[checkboxKey]: true,
...defaultRow
}];
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, showAddRow]);
}, [rows, columns, defaultRow, checkboxKey, createEmptyRow, hasAnyValue, showAddRow]);
const isEmptyRow = useCallback((row) => {
const keyColumn = columns.find((col) => col.isKeyField);
if (!keyColumn) return false;
const value = keyColumn.getValue ? keyColumn.getValue(row) : row[keyColumn.key];
return !value || (typeof value === 'string' && value.trim() === '');
}, [columns]);
// A row is empty when none of its columns hold a value — the single source of
// truth used everywhere (memo guard, persistence filter, last-row rendering).
const isEmptyRow = useCallback((row) => !hasAnyValue(row), [hasAnyValue]);
const isLastEmptyRow = useCallback((row, index) => {
if (!showAddRow) return false;
@@ -227,50 +231,20 @@ const EditableTable = ({
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
if (rowIndex === -1) return;
const currentRow = rowsWithEmpty[rowIndex];
const isLast = rowIndex === rowsWithEmpty.length - 1;
const wasEmpty = isEmptyRow(currentRow);
const keyColumn = columns.find((col) => col.isKeyField);
const isKeyFieldChange = keyColumn && keyColumn.key === key;
let updatedRows = rowsWithEmpty.map((row) => {
const updatedRows = rowsWithEmpty.map((row) => {
if (row.uid === rowUid) {
return { ...row, [key]: value };
}
return row;
});
// Only add a new empty row when the key field is filled
if (showAddRow && isLast && wasEmpty && isKeyFieldChange && value && value.trim() !== '') {
emptyRowUidRef.current = uuid();
updatedRows.push({
uid: emptyRowUidRef.current,
[checkboxKey]: true,
...defaultRow
});
}
const hasAnyValue = (row) => {
for (const col of columns) {
const val = col.getValue ? col.getValue(row) : row[col.key];
const defaultVal = defaultRow[col.key];
if (val && val !== defaultVal && (typeof val !== 'string' || val.trim() !== '')) {
return true;
}
}
return false;
};
const result = updatedRows.filter((row, i) => {
if (showAddRow && i === updatedRows.length - 1) {
return hasAnyValue(row);
}
return true;
});
// Remove any fully-empty rows from the persisted data. The trailing empty
// "add row" is re-added by the rowsWithEmpty memo, so there's always
// exactly one empty row at the bottom and never a stray empty row above it.
const result = showAddRow ? updatedRows.filter(hasAnyValue) : updatedRows;
onChange(result);
}, [rowsWithEmpty, columns, onChange, checkboxKey, defaultRow, isEmptyRow, showAddRow]);
}, [rowsWithEmpty, hasAnyValue, onChange, showAddRow]);
const handleCheckboxChange = useCallback((rowUid, checked) => {
handleValueChange(rowUid, checkboxKey, checked);
@@ -370,17 +344,20 @@ const EditableTable = ({
);
}, [isLastEmptyRow, getRowError, handleValueChange]);
const keyColumn = useMemo(() => columns.find((col) => col.isKeyField), [columns]);
const virtuosoContext = useMemo(() => ({
reorderable,
reorderableRowCount,
isLastEmptyRow,
dragOverRow,
keyColumn,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
onDragEnd: handleDragEnd
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, keyColumn, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
const fixedHeaderContent = useCallback(() => (
<tr>

View File

@@ -1,15 +1,18 @@
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import isEqual from 'lodash/isEqual';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector, useDispatch } from 'react-redux';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import MultiLineEditor from 'components/MultiLineEditor/index';
import DataTypeSelector from 'components/DataTypeSelector';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { BRUNO_VARIABLE_DATATYPES, valueToString } from '@usebruno/common/utils';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
@@ -22,15 +25,27 @@ const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
const MIN_ROW_HEIGHT = 35;
// Non-secret rows first, then secrets. The tabs save independently, so a stable
// order keeps the "modified" comparison accurate regardless of which tab saved last.
const orderVarsBySecret = (vars) => {
const nonSecret = [];
const secret = [];
vars.forEach((v) => (v.secret ? secret : nonSecret).push(v));
return [...nonSecret, ...secret];
};
const TableRow = React.memo(
({ children, item, style, ...rest }) => (
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
{children}
</tr>
),
({ children, item, style, ...rest }) => {
const variable = item?.variable ?? item;
return (
<tr key={variable?.uid} style={style} {...rest} data-testid={`env-var-row-${variable?.name}`}>
{children}
</tr>
);
},
(prevProps, nextProps) => {
const prevUid = prevProps?.item?.uid;
const nextUid = nextProps?.item?.uid;
const prevUid = prevProps?.item?.variable?.uid ?? prevProps?.item?.uid;
const nextUid = nextProps?.item?.variable?.uid ?? nextProps?.item?.uid;
return prevUid === nextUid && prevProps.children === nextProps.children;
}
);
@@ -44,8 +59,10 @@ const EnvironmentVariablesTable = ({
onDraftClear,
setIsModified,
renderExtraValueContent,
searchQuery = ''
searchQuery = '',
variableType = 'variables'
}) => {
const isSecretTab = variableType === 'secrets';
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const activeWorkspace = useSelector((state) => {
@@ -62,7 +79,6 @@ const EnvironmentVariablesTable = ({
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
@@ -151,21 +167,29 @@ 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;
c.activeEnvironmentUid = environment.uid;
if (!collection && workspaceProcessEnvVariables) {
c.workspaceProcessEnvVariables = workspaceProcessEnvVariables;
}
return c;
}, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables, environment.uid]);
// Reuse the previous initialValues when only uids changed but the content is
// identical.
const initialValuesRef = useRef(null);
const initialValues = useMemo(() => {
const vars = environment.variables || [];
return [
const next = [
...vars,
{
uid: uuid(),
@@ -176,6 +200,12 @@ const EnvironmentVariablesTable = ({
enabled: true
}
];
const prev = initialValuesRef.current;
if (prev && isEqual(prev.map(stripEnvVarUid), next.map(stripEnvVarUid))) {
return prev;
}
initialValuesRef.current = next;
return next;
}, [environment.uid, environment.variables]);
const formik = useFormik({
@@ -199,7 +229,9 @@ const EnvironmentVariablesTable = ({
secret: Yup.boolean(),
type: Yup.string(),
uid: Yup.string(),
value: Yup.mixed().nullable()
value: Yup.mixed().nullable(),
dataType: Yup.string().oneOf(BRUNO_VARIABLE_DATATYPES).nullable(),
annotations: Yup.array().nullable()
})
),
validate: (values) => {
@@ -244,7 +276,7 @@ const EnvironmentVariablesTable = ({
name: '',
value: '',
type: 'text',
secret: false,
secret: isSecretTab,
enabled: true
}
]);
@@ -259,6 +291,18 @@ const EnvironmentVariablesTable = ({
setPinnedData({ query: '', uids: new Set() });
}, [savedValuesJson]);
// Keep the trailing empty "add new" row's secret flag in sync with the active
// tab, so typing into it creates a variable of the correct type. The empty row
// is filtered out of save/draft, so this never affects persisted data.
useEffect(() => {
const lastIndex = formik.values.length - 1;
const last = formik.values[lastIndex];
const isEmpty = !last?.name || (typeof last.name === 'string' && last.name.trim() === '');
if (last && isEmpty && !!last.secret !== isSecretTab) {
formik.setFieldValue(`${lastIndex}.secret`, isSecretTab, false);
}
}, [isSecretTab, formik.values]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
@@ -343,7 +387,7 @@ const EnvironmentVariablesTable = ({
name: '',
value: '',
type: 'text',
secret: false,
secret: isSecretTab,
enabled: true
}
];
@@ -358,12 +402,16 @@ const EnvironmentVariablesTable = ({
const isLastRow = index === formik.values.length - 1;
if (isLastRow) {
// Pin the newly-named row's secret flag to the active tab synchronously; the
// passive sync effect runs after paint and is racy for fast input.
formik.setFieldValue(`${index}.secret`, isSecretTab, false);
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
secret: isSecretTab,
enabled: true
};
setTimeout(() => {
@@ -384,17 +432,26 @@ const EnvironmentVariablesTable = ({
};
const handleSave = useCallback(() => {
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const belongsToActiveTab = (variable) => (isSecretTab ? !!variable.secret : !variable.secret);
const namedValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
// Compare without UIDs since they can be different but the actual data is the same
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
// Save is scoped to the active tab. Only the active tab's rows are persisted; the
// other tab keeps its last-saved rows so saving variables never touches secrets and
// vice versa.
const activeCurrent = namedValues.filter(belongsToActiveTab);
const activeSaved = savedValues.filter(belongsToActiveTab);
const otherCurrent = namedValues.filter((variable) => !belongsToActiveTab(variable));
const otherSaved = savedValues.filter((variable) => !belongsToActiveTab(variable));
const hasChanges = JSON.stringify(activeCurrent.map(stripEnvVarUid)) !== JSON.stringify(activeSaved.map(stripEnvVarUid));
if (!hasChanges) {
toast.error('No changes to save');
return;
}
const hasValidationErrors = variablesToSave.some((variable) => {
const hasValidationErrors = activeCurrent.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
@@ -409,72 +466,182 @@ const EnvironmentVariablesTable = ({
return;
}
onSave(cloneDeep(variablesToSave))
// Persist the active tab's edits alongside the other tab's last-saved rows (unchanged).
const persistedVariables = orderVarsBySecret([...activeCurrent, ...otherSaved]);
onSave(cloneDeep(persistedVariables))
.then(() => {
toast.success('Changes saved successfully');
// Preserve unsaved edits on the other tab across the post-save reinit via the
// draft: keep it if the other tab is still dirty, clear it otherwise.
const otherDirty
= JSON.stringify(otherCurrent.map(stripEnvVarUid)) !== JSON.stringify(otherSaved.map(stripEnvVarUid));
const retainedVariables = orderVarsBySecret([...activeCurrent, ...otherCurrent]);
if (otherDirty) {
onDraftChange(cloneDeep(retainedVariables));
} else {
onDraftClear();
}
formik.resetForm({
values: [
...retainedVariables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: isSecretTab,
enabled: true
}
]
});
setIsModified(otherDirty);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, onDraftChange, onDraftClear, setIsModified, isSecretTab]);
const handleReset = useCallback(() => {
const belongsToActiveTab = (variable) => (isSecretTab ? !!variable.secret : !variable.secret);
const savedValues = environment.variables || [];
const activeSaved = savedValues.filter(belongsToActiveTab);
const otherSaved = savedValues.filter((variable) => !belongsToActiveTab(variable));
const otherCurrent = formik.values
.filter((variable) => variable.name && variable.name.trim() !== '')
.filter((variable) => !belongsToActiveTab(variable));
// Reset is scoped to the active tab: revert its rows to the saved baseline while
// leaving the other tab's current (possibly unsaved) edits intact.
const resetVariables = orderVarsBySecret([...activeSaved, ...otherCurrent]);
const otherDirty
= JSON.stringify(otherCurrent.map(stripEnvVarUid)) !== JSON.stringify(otherSaved.map(stripEnvVarUid));
if (otherDirty) {
onDraftChange(cloneDeep(resetVariables));
} else {
onDraftClear();
}
formik.resetForm({
values: [
...resetVariables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: isSecretTab,
enabled: true
}
]
});
setIsModified(otherDirty);
}, [environment.variables, formik.values, isSecretTab, onDraftChange, onDraftClear, setIsModified]);
const handleSaveAll = useCallback(() => {
const namedValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const persistedVariables = orderVarsBySecret(namedValues);
const hasChanges
= JSON.stringify(persistedVariables.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
if (!hasChanges) {
toast.error('No changes to save');
return;
}
const hasValidationErrors = namedValues.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
onSave(cloneDeep(persistedVariables))
.then(() => {
toast.success('Changes saved successfully');
onDraftClear();
const newValues = [
...variablesToSave,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: newValues });
formik.resetForm({
values: [
...persistedVariables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: isSecretTab,
enabled: true
}
]
});
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
const handleReset = useCallback(() => {
const originalVars = environment.variables || [];
const resetValues = [
...originalVars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: resetValues });
setIsModified(false);
}, [environment.variables, setIsModified]);
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified, isSecretTab]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const handleSaveAllRef = useRef(handleSaveAll);
handleSaveAllRef.current = handleSaveAll;
useEffect(() => {
const handleSaveEvent = () => {
handleSaveRef.current();
};
const handleSaveAllEvent = () => {
handleSaveAllRef.current();
};
window.addEventListener('environment-save', handleSaveEvent);
window.addEventListener('environment-save-all', handleSaveAllEvent);
return () => {
window.removeEventListener('environment-save', handleSaveEvent);
window.removeEventListener('environment-save-all', handleSaveAllEvent);
};
}, []);
const filteredVariables = useMemo(() => {
const allVariables = formik.values.map((variable, index) => ({ variable, index }));
const lastIndex = formik.values.length - 1;
// Show only rows belonging to the active tab, but always keep the trailing
// empty "add new" row so the user can add a variable/secret on either tab.
const tabVariables = formik.values
.map((variable, index) => ({ variable, index }))
.filter(({ variable, index }) => {
const isLastEmptyRow
= index === lastIndex && (!variable.name || (typeof variable.name === 'string' && variable.name.trim() === ''));
if (isLastEmptyRow) return true;
return isSecretTab ? !!variable.secret : !variable.secret;
});
if (!searchQuery?.trim()) {
return allVariables;
return tabVariables;
}
const query = searchQuery.toLowerCase().trim();
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
return allVariables.filter(({ variable }) => {
return tabVariables.filter(({ variable }) => {
if (effectivePins.has(variable.uid)) return true;
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueText
@@ -486,7 +653,7 @@ const EnvironmentVariablesTable = ({
const valueMatch = valueText.toLowerCase().includes(query);
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery, pinnedData]);
}, [formik.values, searchQuery, pinnedData, isSecretTab]);
const isSearchActive = !!searchQuery?.trim();
@@ -516,10 +683,10 @@ const EnvironmentVariablesTable = ({
/>
</td>
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
defaultItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
@@ -565,28 +732,22 @@ const EnvironmentVariablesTable = ({
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
className="flex flex-row flex-nowrap items-center gap-2"
style={{ width: columnWidths.value }}
>
<div
className="overflow-hidden grow w-full relative"
className="flex-1 min-w-0 relative"
onFocus={() => handleRowFocus(variable.uid)}
>
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
value={valueToString(variable.value, 2)}
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
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(() => {
@@ -595,7 +756,7 @@ const EnvironmentVariablesTable = ({
name: '',
value: '',
type: 'text',
secret: false,
secret: isSecretTab,
enabled: true
}, false);
}, 0);
@@ -604,29 +765,22 @@ const EnvironmentVariablesTable = ({
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
{!isLastEmptyRow && (
<span>
<DataTypeSelector
variable={variable}
theme={storedTheme}
collection={_collection}
onChange={(fields) => {
Object.entries(fields).forEach(([key, val]) => {
formik.setFieldValue(`${actualIndex}.${key}`, val, true);
});
}}
/>
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>

View File

@@ -33,15 +33,15 @@ const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose,
<div className="flex justify-between mt-6">
<div>
<Button color="danger" onClick={onCloseWithoutSave}>
<Button color="danger" onClick={onCloseWithoutSave} data-testid="env-unsaved-close-without-save">
Don't Save
</Button>
</div>
<div className="flex gap-2">
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel}>
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel} data-testid="env-unsaved-cancel">
Cancel
</Button>
<Button onClick={onSaveAndClose}>
<Button onClick={onSaveAndClose} data-testid="env-unsaved-save-and-close">
Save
</Button>
</div>

View File

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

View File

@@ -21,14 +21,14 @@ const DeleteEnvironment = ({ onClose, environment, collection }) => {
<Portal>
<StyledWrapper>
<Modal
size="sm"
size="md"
title="Delete Environment"
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
confirmButtonColor="danger"
>
Are you sure you want to delete <span className="font-medium">{environment.name}</span> ?
Are you sure you want to delete <span className="font-medium">{environment.name}</span>?
</Modal>
</StyledWrapper>
</Portal>

View File

@@ -9,7 +9,7 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '', variableType = 'variables' }) => {
const dispatch = useDispatch();
const environmentsDraft = collection?.environmentsDraft;
@@ -92,7 +92,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
return (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
warningMessage="This variable is used in sensitive fields. Add it as a secret in the Secrets tab for security"
/>
);
}
@@ -113,6 +113,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
setIsModified={setIsModified}
renderExtraValueContent={renderExtraValueContent}
searchQuery={searchQuery}
variableType={variableType}
/>
);
};

View File

@@ -96,6 +96,17 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
gap: 2px;
}
}
.tabs-container {
padding: 0 20px;
flex-shrink: 0;
.env-search-container {
display: flex;
align-items: center;
gap: 2px;
.search-input-wrapper {
position: relative;
@@ -150,30 +161,6 @@ const StyledWrapper = styled.div`
}
}
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&:last-child:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
}
}
@@ -183,6 +170,7 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
padding: 0 20px 20px 20px;
margin-top: 16px;
}
`;

View File

@@ -1,6 +1,6 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconDeviceFloppy } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -8,8 +8,16 @@ import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnv
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import ActionIcon from 'ui/ActionIcon';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import { updateTabState } from 'providers/ReduxStore/slices/tabs';
import StyledWrapper from './StyledWrapper';
const TABS = [
{ key: 'variables', label: 'Variables' },
{ key: 'secrets', label: 'Secrets' }
];
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
const dispatch = useDispatch();
const environments = collection?.environments || [];
@@ -19,7 +27,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeTab = useSelector((state) => state.tabs.tabs.find((t) => t.uid === activeTabUid)?.tabState?.envTab) || 'variables';
const setActiveTab = (tab) => dispatch(updateTabState({ uid: activeTabUid, tabState: { envTab: tab } }));
const inputRef = useRef(null);
const rightContentRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
@@ -133,6 +145,10 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
};
const handleSaveAll = () => {
window.dispatchEvent(new Event('environment-save-all'));
};
return (
<StyledWrapper>
{openDeleteModal && (
@@ -187,48 +203,66 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder="Search variables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
<ActionIcon label="Rename" onClick={handleRenameClick} data-testid="env-rename-action">
<IconEdit size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Copy" onClick={() => setOpenCopyModal(true)} data-testid="env-copy-action">
<IconCopy size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Delete" onClick={() => setOpenDeleteModal(true)} colorOnHover="danger" data-testid="env-delete-action">
<IconTrash size={15} strokeWidth={1.5} />
</ActionIcon>
</div>
</div>
<div className="tabs-container">
<ResponsiveTabs
tabs={TABS}
activeTab={activeTab}
onTabSelect={setActiveTab}
rightContent={(
<div ref={rightContentRef} className="env-search-container">
<ActionIcon label="Save" onClick={handleSaveAll} data-testid="save-all-env">
<IconDeviceFloppy size={15} strokeWidth={1.5} />
</ActionIcon>
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder={activeTab === 'secrets' ? 'Search secrets...' : 'Search variables...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
data-testid="env-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
data-testid="env-clear-search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
) : (
<ActionIcon label="Search" onClick={handleSearchIconClick} data-testid="env-search-action">
<IconSearch size={15} strokeWidth={1.5} />
</ActionIcon>
)}
</div>
) : (
<button onClick={handleSearchIconClick} title="Search variables">
<IconSearch size={15} strokeWidth={1.5} />
</button>
)}
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenCopyModal(true)} title="Copy">
<IconCopy size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenDeleteModal(true)} title="Delete">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</div>
rightContentRef={rightContentRef}
/>
</div>
<div className="content">
@@ -237,6 +271,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
variableType={activeTab}
/>
</div>
</StyledWrapper>

View File

@@ -45,7 +45,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
@@ -79,7 +79,7 @@ const StyledWrapper = styled.div`
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.colors.accent};
@@ -111,6 +111,7 @@ const StyledWrapper = styled.div`
flex-direction: column;
overflow: hidden;
padding: 8px;
border-right: 1px solid ${(props) => props.theme.border.border0};
}
.section-header {
@@ -163,7 +164,7 @@ const StyledWrapper = styled.div`
cursor: pointer;
border-radius: 6px;
transition: background 0.15s ease;
.environment-name {
flex: 1;
white-space: nowrap;
@@ -216,18 +217,18 @@ const StyledWrapper = styled.div`
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
&.active {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
&.renaming,
&.creating {
cursor: default;
padding: 4px 4px 4px 8px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
@@ -239,7 +240,7 @@ const StyledWrapper = styled.div`
flex: 1;
min-width: 0;
overflow: hidden;
.environment-name-input {
flex: 1;
min-width: 0;
@@ -249,12 +250,12 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
font-size: 13px;
padding: 2px 4px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.inline-actions {
display: flex;
gap: 2px;
@@ -273,12 +274,12 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
font-size: 13px;
padding: 2px 4px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.inline-actions {
display: flex;
gap: 2px;
@@ -299,25 +300,25 @@ const StyledWrapper = styled.div`
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.colors.text.green};
&:hover {
background: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
&:hover {
background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
}
}
}
}
.env-error {
padding: 4px 12px;
margin-top: 4px;

View File

@@ -1,16 +1,33 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { updateTabState } from 'providers/ReduxStore/slices/tabs';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
const EnvironmentSettings = ({ collection }) => {
const dispatch = useDispatch();
const [isModified, setIsModified] = useState(false);
const environments = collection?.environments || [];
const [selectedEnvironment, setSelectedEnvironment] = useState(() => {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const persistedEnvUid = useSelector((state) => state.tabs.tabs.find((t) => t.uid === activeTabUid)?.tabState?.envUid);
// Remember which environment the user last viewed in this tab (via tabState) so navigating away and back preserves it.
const selectedEnvironment = useMemo(() => {
if (!environments.length) return null;
return environments.find((env) => env.uid === collection?.activeEnvironmentUid) || environments[0];
});
return (
environments.find((env) => env.uid === persistedEnvUid)
|| environments.find((env) => env.uid === collection?.activeEnvironmentUid)
|| environments[0]
);
}, [environments, persistedEnvUid, collection?.activeEnvironmentUid]);
const setSelectedEnvironment = (env) => {
if (!activeTabUid || !env?.uid) return;
dispatch(updateTabState({ uid: activeTabUid, tabState: { envUid: env.uid } }));
};
const [showExportModal, setShowExportModal] = useState(false);
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -4,11 +4,13 @@ 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 { useRef } from 'react';
import { useMemo, 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 AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload, buildDocsContextFromFolder } from 'utils/ai';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
@@ -43,6 +45,8 @@ const Documentation = ({ collection, folder }) => {
};
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const docsContext = useMemo(() => buildDocsContextFromFolder(collection, folder), [collection, folder]);
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]);
if (!folder) {
return null;
@@ -56,7 +60,7 @@ const Documentation = ({ collection, folder }) => {
{isEditing ? (
<div className="flex flex-col flex-1 min-h-0">
<div className="mt-2 flex-1 overflow-auto min-h-0">
<div className="mt-2 flex-1 overflow-auto min-h-0 relative">
<CodeEditor
collection={collection}
theme={displayedTheme}
@@ -69,6 +73,7 @@ const Documentation = ({ collection, folder }) => {
initialScroll={scroll}
onScroll={setScroll}
/>
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} variables={aiVariables} onApply={onEdit} />
</div>
<div className="mt-6 flex-shrink-0">
<Button type="submit" size="sm" onClick={onSave}>

View File

@@ -1,8 +1,10 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload } from 'utils/ai';
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -13,6 +15,7 @@ import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useFocusErrorLine } from 'hooks/useFocusErrorLine';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -22,7 +25,9 @@ const Script = ({ collection, folder }) => {
const responseScript = folder.draft ? get(folder, 'draft.request.script.res', '') : get(folder, 'root.request.script.res', '');
const tabs = useSelector((state) => state.tabs.tabs);
const focusedTab = find(tabs, (t) => t.uid === folder.uid);
const focusedTab = find(tabs, (tab) => tab.type === 'folder-settings' && (tab.uid === folder.uid || tab.folderUid === folder.uid))
|| find(tabs, (tab) => tab.type === 'folder-settings' && tab.pathname === folder.pathname);
const tabUid = focusedTab?.uid || folder.uid;
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
@@ -34,7 +39,7 @@ const Script = ({ collection, folder }) => {
const activeTab = scriptPaneTab || getDefaultTab();
const setActiveTab = (tab) => {
dispatch(updateScriptPaneTab({ uid: folder.uid, scriptPaneTab: tab }));
dispatch(updateScriptPaneTab({ uid: tabUid, scriptPaneTab: tab }));
};
const { displayedTheme } = useTheme();
@@ -60,6 +65,20 @@ const Script = ({ collection, folder }) => {
return () => clearTimeout(timer);
}, [activeTab]);
useFocusErrorLine({
uid: folder.uid,
editorRef: preRequestEditorRef,
scriptPhase: 'pre-request',
isVisible: activeTab === 'pre-request'
});
useFocusErrorLine({
uid: folder.uid,
editorRef: postResponseEditorRef,
scriptPhase: 'post-response',
isVisible: activeTab === 'post-response'
});
const onRequestScriptEdit = (value) => {
dispatch(
updateFolderRequestScript({
@@ -84,6 +103,8 @@ const Script = ({ collection, folder }) => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]);
const items = flattenItems(folder.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
@@ -111,39 +132,57 @@ const Script = ({ collection, folder }) => {
</TabsList>
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
scriptType="pre-request"
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
variables={aiVariables}
onApply={onRequestScriptEdit}
/>
</div>
</TabsContent>
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<div className="relative h-full">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
mode="javascript"
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
scriptType="post-response"
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
variables={aiVariables}
onApply={onResponseScriptEdit}
/>
</div>
</TabsContent>
</Tabs>

View File

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

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