Compare commits

...

127 Commits

Author SHA1 Message Date
maintainer-bruno
afaebf6b3d Merge pull request #4796 from lohxt1/collection_auth_default_values_issue
fix: collection auth default value access fix and validations
2025-05-29 18:28:57 +05:30
lohit
6e89001825 fix: collection auth default value access fix and validations 2025-05-29 17:45:42 +05:30
lohit
e7dd78ea53 Merge pull request #4775 from lohxt1/axios_instance_redirect_error_fix
fix: return the actual axios error in bruno-cli' axios-instance for url-redirect related errors
2025-05-27 20:27:48 +05:30
lohit
9ad0f2d169 revert custom error messages 2025-05-27 19:40:51 +05:30
lohit
bf19645282 revert test update 2025-05-27 19:40:22 +05:30
lohit
bb01199877 updates 2025-05-27 19:17:05 +05:30
lohit
5627c5624f updates 2025-05-27 19:16:29 +05:30
lohit
8e23a7054f Merge pull request #4770 from lohxt1/error_requests_cli_tests_issue
fix: consider request execution errors as `CLI Tests` workflow failure
2025-05-27 18:49:02 +05:30
lohit
d820069371 return the actual axios error with the custom error message in bruno-cli axios-instance 2025-05-27 18:41:47 +05:30
lohit
2de9b87c6f consider errored request as a collection run fail 2025-05-27 15:30:54 +05:30
lohit
178773d63a Merge pull request #4173 from Pragadesh-45/feat/custom-installation-path
Feat/ Custom installation path for GUI installer on Windows
2025-05-27 11:49:29 +05:30
lohit
7994946c85 Merge pull request #4764 from lohxt1/shortcut_key_new_request_issue
fix: new request shortcut key
2025-05-27 11:49:15 +05:30
lohit
b020255269 Merge pull request #4662 from sanjaikumar-bruno/fix/cli-not-following-redirects
feat: enhance axios instance with redirect handling and cookie management in CLI
2025-05-27 11:48:43 +05:30
lohit
73b0f0919d Merge pull request #4679 from anusree-bruno/bugfix/timestamp-current-time
fix: ensure timestamp and isoTimestamp return current time instead of random values
2025-05-26 22:29:53 +05:30
Pragadesh-45
8975b9eef6 fix: update Windows build configuration for icon and publisher name 2025-05-26 21:06:01 +05:45
lohit
865e813b42 revert test bru file 2025-05-26 20:45:33 +05:30
lohit
51f36b1903 Merge pull request #4038 from Chriss4123/feature/localhost-secure-context
feat: Add RFC 6761–compliant localhost loopback checks so `secure` cookies work on localhost (fixes: #1676)
2025-05-26 17:16:33 +05:30
Clay Powers
6b122d7262 Switch GraphQL variables code editor to json linting (#4756) 2025-05-26 16:55:11 +05:30
lohit
a8e5ce9c13 fix: new request shortcut key 2025-05-26 14:58:25 +05:30
anusree-bruno
8ac916b0ff removed unwanted tests 2025-05-26 14:49:21 +05:30
anusree-bruno
8d860a051c replace real time with mocked time in faker tests 2025-05-26 14:43:23 +05:30
Anoop M D
4ac2c4ac34 Merge pull request #4706 from ved-bruno/e2e_support
Playwright: Support Element Verification
2025-05-23 21:11:27 +05:30
maintainer-bruno
7c27193983 chore: add CODEOWNERS file for repository maintenance 2025-05-23 16:57:48 +05:30
ramki-bruno
2c3d2ff6a7 Make Secure-local-cookies work in CLI as well 2025-05-23 13:49:56 +05:30
Chriss4123
a4fff01647 Support Secure cookies for localhost and loopback addresses 2025-05-23 12:35:04 +05:30
sanjai0py
2cd985faf7 Remove test file for redirects with cookies 2025-05-23 08:58:28 +05:30
sanish chirayath
9a35302d4b Feature: implemented bru.interpolate (#4122)
* feat: enhance variable highlighting in CodeMirror and update interpolation method

* feat: add interpolate function to bru shim and corresponding tests

- Implemented the `interpolate` function in the bru shim to handle variable interpolation.
- Added a new test case for the `interpolate` function to verify its functionality with mock variables.

* feat: enhance interpolate function to support object interpolation

* feat: add translation support for pm.variables.replaceIn to bru.interpolate

* revert: eslint config changes

* revert: eslint config changes

* fix: update method call to use correct interpolation function in Bru class

* refactor: added jsdoc to codemirror highlighting code

* fix: higlighting for multiline editor
2025-05-22 15:37:15 +05:30
Pooja
553f7675f2 fix: request timer reset while switching tabs (#4165)
* fix" request timer reset while switching tabs

* fix

* rm: extraReducers

* improve: logic

* fix: pass startTime as prop

* fix

* fix: directly use collection in setRequestStartTime

* rm: reseting start time null
2025-05-22 15:36:26 +05:30
sanjai0py
b299879b82 Refactor saveCookies function to remove disableCookies parameter and streamline cookie handling in response interceptors 2025-05-21 17:00:22 +05:30
Pooja
3696562414 fix: circular recursion for openapi import (#4729) 2025-05-21 15:10:35 +05:30
devendra-bruno
e02c6c274b Fix/svg render respone panel (#4655)
* Refactor getContentType in utils

* Add testcases for getContentType

* Refactor getContent

* Refactor getContent

* Refactor getContent

* Added testcase of case insensitivity

* Added content-type case in sensitive

* Refactor testcase spec

* Added testcases for empty content-type
2025-05-21 13:45:06 +05:30
sanjai0py
ab0a4b8140 Add disableCookies option to axios instance and saveCookies function 2025-05-19 15:08:12 +05:30
sanjai0py
1b268ae9db Merge branch 'main' into fix/cli-not-following-redirects 2025-05-19 14:36:54 +05:30
ved-bruno
8debb9fd11 made suggested changes for support element verification 2025-05-19 14:02:34 +05:30
naman-bruno
7c07488e16 Merge pull request #4697 from lohxt1/req_get_name_test
fix: bruno converters test for reg.getName()
2025-05-16 21:52:30 +05:30
lohit
6073a9e2c3 fix bruno converters test for reg.getName() 2025-05-16 21:27:00 +05:30
lohit
9c652f86c9 Merge pull request #4696 from naman-bruno/bugfix/noproxy-option
add: noproxy flag in options
2025-05-16 21:06:29 +05:30
naman-bruno
3c0090d86f fix: runSingleRequest function 2025-05-16 21:02:24 +05:30
naman-bruno
9132755d49 add: noproxy in options 2025-05-16 20:52:15 +05:30
lohit
2a44691cb3 Merge pull request #4590 from poojabela/feat/add-getName-api-for-script
feat: add `req.getName` & `bru.getCollectionName` api
2025-05-16 20:21:35 +05:30
lohit
0d8a696498 Merge pull request #4609 from pooja-bruno/feat/extend-support-for-more-auth-for-folder-level
feat: extend support for more auth in folder level
2025-05-16 20:20:33 +05:30
lohit
bfa2706598 Merge pull request #4366 from Pragadesh-45/fix/import-curl
Feat: Enhance curl parsing for multipart form data
2025-05-16 20:20:18 +05:30
ved-bruno
5fdb52388a support element verification 2025-05-16 19:34:39 +05:30
Pooja
799dc9a1ca feat: add function in bruno converters package (#4669)
* feat: add  function in bruno converters package

* add: example for openapi yaml to bruno conversion

* add: converting json to yaml in converters

* fix
2025-05-16 17:26:40 +05:30
naman-bruno
2bb56e8a4b Fix: properly handling redirects with status code (#4561)
* Fix: properly handling redirects with status code

* fix: updated redirect logic for method change
2025-05-16 17:14:37 +05:30
Pragadesh-45
084d2bf692 test: add unit tests for basic functionality, headers, auth, and form data handling in parseCurlCommand 2025-05-16 14:32:30 +05:45
Pragadesh-45
10640c7561 feat: enhance curl parsing for multipart form data
- Updated `parseCurlCommand` to handle `-F` and `--form` flags, allowing for multiple form fields with file uploads.
- Adjusted `curlToJson` to set `Content-Type` for multipart data and handle binary data correctly.
2025-05-16 14:32:05 +05:45
naman-bruno
9f044c48fe Added proxy flag for cli (#3963)
* system level proxy depends on proxy flag

* added proxy flag

* fix: proxy flag behaviour

* fix: noproxy flag logic
2025-05-16 14:02:11 +05:30
Anoop M D
79f4e69a05 Merge pull request #4160 from usebruno/feature/custom-user-data-path-for-dev 2025-05-15 16:18:28 +05:30
ramki-bruno
f13148af3d Added option to customize userData path on dev mode
If `ELECTRON_APP_NAME` env-variable is present and its development mode,
then the `appName` and `userData` path is modified accordingly.

e.g.
```sh
ELECTRON_APP_NAME=bruno-dev npm run dev:electron
```

Note: This doesn't change the name of the window or the names in lot of
other places, only the name used by Electron internally.
2025-05-15 16:12:51 +05:30
anusree-bruno
d2eb2d2941 fix: ensure timestamp and isoTimestamp return current time instead of random values 2025-05-15 14:11:53 +05:30
Anoop M D
942c0ee113 Merge pull request #4671 from lohxt1/lint_gh_workflow_step
add lint step to the unit-tests gh workflow
2025-05-14 17:58:54 +05:30
pooja-bruno
fbd3a38587 fix 2025-05-14 17:55:50 +05:30
pooja-bruno
45b660985e fix: ui 2025-05-14 17:45:03 +05:30
pooja-bruno
0888125899 add: default auth mode inherit in folder 2025-05-14 16:12:48 +05:30
pooja-bruno
c85d9bcd84 fix: folder inherit auth 2025-05-14 16:01:42 +05:30
lohit
dbf8af1146 Merge branch 'main' into lint_gh_workflow_step 2025-05-14 15:41:35 +05:30
lohit
d7ccf1454e revert lint-staged step, make lint check as part of gh unit-tests workflow 2025-05-14 14:05:49 +05:30
Anoop M D
652d447f8b Merge pull request #4667 from usebruno/feature/playwright
Customize Playwright reporter and retries for dev and CI env
2025-05-14 13:28:03 +05:30
ramki-bruno
2f58379feb Customize Playwright reporter and retries for dev and CI env 2025-05-14 11:54:56 +05:30
sanjai0py
c14d3f4274 feat: add test case for redirects with cookie authentication 2025-05-14 10:46:14 +05:30
Anoop M D
d4673a2f07 Merge pull request #4665 from lohxt1/eslint_node_space_issue
fix: eslint - javascript heap out of memory
2025-05-14 00:34:03 +05:30
Anoop M D
3a0c94577f Merge pull request #4666 from sreelakshmi-bruno/update_contributing_guidelines
Updating contributing.md
2025-05-13 23:59:59 +05:30
sanjai0py
5a4e33e503 Merge branch 'main' into fix/cli-not-following-redirects 2025-05-13 20:07:29 +05:30
sanjai0py
5649799167 feat: add maxRedirects configuration to runSingleRequest 2025-05-13 20:02:29 +05:30
sreelakshmi-bruno
c407b73c22 Updating contributing.md 2025-05-13 19:42:00 +05:30
lohit
361add3385 handle folder run action while a collection run is in progress (#4658)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-13 19:41:39 +05:30
lohit
9d6ab69d37 eslint - node out of memeory issue 2025-05-13 19:21:30 +05:30
sanjai0py
0f6da35c0b feat: enhance axios instance with redirect handling and cookie management 2025-05-13 17:27:55 +05:30
ramki-bruno
b699088dd6 Create/Import collection UX improvements (#4540)
* Fix: Improve UX for selecting location when create/import collection

Allow editing the input path where previously the `<input>` is marked
`readonly`.
Also this will allow automating test using Playwright.

* Fix: Import-collection select-location Modal closes on error

* Improved error-toast for creating and importing collections

- Added a util for formatting the error form IPC
- Updated Toast global styles to prevent text overflow.
  Whenever long file paths are shown, it overflows the Toast container.
2025-05-13 14:25:35 +05:30
ganesh
458c070004 Fix: Specify Node.js version in Contributing Guide (#4656)
* fix node version on contributing file

* updated to node 22 version
2025-05-13 14:10:10 +05:30
Anoop M D
babac6df3c Merge pull request #4651 from usebruno/feature/playwright
Playwright Codegen and CI setup
2025-05-12 22:08:31 +05:30
Pooja
f58477931f feat: add support for oauth2 in cli (#4578)
Co-authored-by: Pooja Belaramani <109731557+poojabela@users.noreply.github.com>
2025-05-12 21:37:42 +05:30
lohit
2171d491a6 Merge pull request #4641 from lohxt1/folder_sequencing_cli
refactor: `bruno-cli` - follow folder/request sequences during collection runs
2025-05-12 21:19:58 +05:30
ramki-bruno
aa911f88f2 Playwright Codegen and CI setup
- Improved the Codegen setup
  - Removed the app-launch related boilerplate from tests
  - Enable recording mode by default
  - Option to provide the test file name to save the recording
- Added GitHub workflow to run Playwright tests with Electron in
  Headless mode(mocking display using `xvfb`).
2025-05-12 20:35:05 +05:30
lohit
bdbcaeff67 updates 2025-05-12 20:06:26 +05:30
lohit
b2756b3c63 updates 2025-05-12 17:37:45 +05:30
lohit
27f11ab583 updates 2025-05-12 17:19:13 +05:30
lohit
2776970317 revert collection-pathname param 2025-05-12 16:57:27 +05:30
Anoop M D
9d28bf7e82 Merge pull request #4571 from pooja-bruno/feat/extend-cli-auth-for-Inherit-and-req
feat: extend cli auth for inherit and request
2025-05-12 16:15:17 +05:30
ramki-bruno
6455b00742 Removed old Playwright setup 2025-05-12 12:12:21 +05:30
lohit
16179a3b50 refactored getCollectionJsonFromPathname function and added tests 2025-05-11 23:08:44 +05:30
Anoop M D
6a37c9d076 Merge pull request #4636 from sreelakshmi-bruno/add-build-commands
Adding build instructions for new packages
2025-05-10 17:03:14 +05:30
Anoop M D
1915b1c00a Fix/add missing translations (#4637)
* fix: add missing deps
* feat: add missing translations
* fix: regex tranasaltion for to.have.headers
2025-05-10 16:59:21 +05:30
lohit
a9982d6e28 removed unused collectionPathnmae prop to components (#4640)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-10 15:11:26 +05:30
sanish-bruno
1daeb8fe93 fix: regex tranasaltion for to.have.headers 2025-05-09 19:12:52 +05:30
sanish-bruno
3dfb158382 feat: add missing translations 2025-05-09 17:56:32 +05:30
sanish-bruno
fb7d247fa7 fix: add missing deps 2025-05-09 17:37:16 +05:30
lohit
6bf2312a94 Merge branch 'main' into folder_sequencing_cli 2025-05-09 16:34:50 +05:30
sreelakshmi-bruno
0cdcb83a7a Adding build instructions for new packages 2025-05-09 15:48:49 +05:30
sanish chirayath
e4f48e81fc feat: add setBody test script to bruno-tests collection (#4415) 2025-05-09 14:16:29 +05:30
lohit
1d32a95a09 Merge pull request #4628 from lohxt1/fix_tests_converters_package
fix: tests for postmanToBrunoEnvironment function
2025-05-09 01:45:56 +05:30
lohit
4c934a78a6 fix tests for postmanToBrunoEnvironment function 2025-05-09 00:07:58 +05:30
Pooja
c47bc86d37 fix: Improve Variable Highlighting in Code Generation and Environment Views (#4593) 2025-05-08 21:53:14 +05:30
Sanjai Kumar
a125781312 feat/replace unsupported characters in env key during pm import (#4618)
---------

Co-authored-by: sanjai0py <sanjailucifer666@gmail.com>
2025-05-08 21:52:58 +05:30
sanish chirayath
dfa951e574 Feature: postman to bru translator (#4534) 2025-05-08 21:51:21 +05:30
naman-bruno
76779e6f95 Merge pull request #4615 from pooja-bruno/feat/add-openapi-to-bruno-import-in-cli
Feat: add openapi to bruno import in cli
2025-05-08 19:53:00 +05:30
lohit
e9a79a32da lint error fixes (#4623)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-08 18:12:16 +05:30
lohit
967170a7b2 eslint for bruno-app and bruno-electron packages (#4622)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-08 18:09:55 +05:30
Anoop M D
3326784315 Merge pull request #4620 from lohxt1/fix_regex_tests
fix: tests for sanitizeName function
2025-05-08 18:07:41 +05:30
lohit
fc553e1009 fix sanitize name function tests in bruno-electron 2025-05-07 22:24:52 +05:30
lohit
da172ff9b5 fix sanitize name function tests 2025-05-07 22:12:57 +05:30
lohit
fc422853ef update T_RunnerResults type to include summary prop (#4617)
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-07 19:19:40 +05:30
Pooja Belaramani
2852c07ec7 feat: support tv4 as a inbuilt lib (#4589) 2025-05-07 17:44:29 +05:30
Anoop M D
ead1c9ecab Merge pull request #4542 from pooja-bruno/fix/app-crash-when-we-rename-folder-in-fs
fix: app crash when we rename folder in fs
2025-05-07 17:42:09 +05:30
Anoop M D
5b5066577f Merge pull request #4373 from vishnuprasanth-j/bugfix/regression-4350
Fix: Leading dot is not allowed for collections, folders and requests
2025-05-07 17:39:25 +05:30
sreelakshmi-bruno
4af0bb3943 Fix: ResponseSize component logic to handle size when it's undefined (#4613)
* Fix: ResponseSize component logic to handle size when it's undefined
* Improved check for valid response size

---------

Co-authored-by: Sreelakshmi Jayarajan <sreelakshmi@Sreelakshmis-MacBook-Air.local>
2025-05-07 16:59:35 +05:30
pooja-bruno
f2eaa79318 Feat: add openapi to bruno import in cli 2025-05-07 15:36:21 +05:30
lohit
2ee7ce5829 persist request/folder uids after request/folder resequencing and ui updates (#4611)
* move file/folder uids to new paths

* drag file/folder preview ui updates, can item be dropped ui hint check

---------

Co-authored-by: lohit <lohit@usebruno.com>
2025-05-06 22:20:59 +05:30
pooja-bruno
0d7c94e7e9 add: auth for other 2025-05-06 18:41:40 +05:30
pooja-bruno
9e29821012 feat: extend support for more auth in folder level 2025-05-06 17:56:34 +05:30
lohit
38c307d6f1 feat: folder sequencing (#4595)
Co-authored-by: Pooja Belaramani <pooja@usebruno.com>
Co-authored-by: naman-bruno <naman@usebruno.com>
Co-authored-by: lohit <lohit@usebruno.com>
2025-05-05 16:52:00 +05:30
lohit
520567793a feat(cli): Refactor request runner and improve folder sorting
~ remove duplicate code by consolidating request collection functionality
~ add proper sorting of folder and request items based on sequence numbers
~ add error request count to run summary output
~ update dev dependencies with proper markings in package-lock.json and removed previously added currently reverted entries
2025-05-03 23:50:12 +05:30
poojabela
e0fb379511 add: bru.collectionName api 2025-04-30 17:25:42 +05:30
poojabela
ba9362ccb2 add: getName in collection 2025-04-30 15:36:44 +05:30
poojabela
261a36c435 add: getName in hint 2025-04-30 15:29:10 +05:30
poojabela
cb92e46f8d feat: add req.getName api 2025-04-30 15:14:36 +05:30
Pooja Belaramani
126186041e add: tests for request level auth 2025-04-28 15:18:18 +05:30
Pooja Belaramani
6379e1703c feat: extend cli auth for inherit and request 2025-04-28 12:52:52 +05:30
Pooja Belaramani
2b246e431b change: no found folder tab 2025-04-25 15:58:53 +05:30
Pooja Belaramani
529803f791 fix: app crash when we rename folder in fs 2025-04-22 13:18:25 +05:30
ramki-bruno
b93d8e73a2 Allow leading dot for file and folder names
Co-authored-by: vishnuprasanth-j <jvpvis6@gmail.com>
2025-04-16 03:26:38 +05:30
ramki-bruno
17c9813c98 Regularize RegEx patterns for validating filenames
- Fixed inconsistency in validating last character between
  bruno-electron and bruno-app.
- Fixed inconsistency in sanitizing first character between
  bruno-electron and bruno-app.
- Updated the comments to accurately reflect the patterns
- Fixed inconsistencies in escaping certain characters in the patterns
  itself.
2025-04-16 03:20:58 +05:30
Pragadesh-45
f6ab59ceda feat: update Windows build configuration to support custom installation path from GUI installer 2025-03-06 17:40:15 +05:30
Pragadesh-45
f1004e2e36 Merge branch 'main' of https://github.com/Pragadesh-45/bruno 2025-03-06 00:27:18 +05:30
Pragadesh-45
26eaec4c72 Merge branch 'usebruno:main' into main 2025-02-07 10:01:15 +05:30
Pragadesh-45
d0419edb92 fix: correct variable used in collection name update 2025-02-04 17:49:40 +05:30
226 changed files with 11659 additions and 1774 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @helloanoop @maintainer-bruno @lohit-bruno @naman-bruno

44
.github/workflows/playwright.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Playwright E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e-test:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
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
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
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@@ -31,6 +31,9 @@ jobs:
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
- name: Lint Check
run: npm run lint
# tests
- name: Test Package bruno-js
run: npm run test --workspace=packages/bruno-js

6
.gitignore vendored
View File

@@ -46,4 +46,8 @@ yarn-error.log*
#dev editor
bruno.iml
.idea
.idea
.vscode
# Playwright
/blob-report/

View File

@@ -15,15 +15,15 @@
| [正體中文](docs/contributing/contributing_zhtw.md)
| [日本語](docs/contributing/contributing_ja.md)
| [हिंदी](docs/contributing/contributing_hi.md)
| [Nederlands](docs/contributing/contributing_nl.md)
| [Dutch](docs/contributing/contributing_nl.md)
## Let's make Bruno better, together!!
We are happy that you are looking to improve Bruno. Below are the guidelines to get started bringing up Bruno on your computer.
We are happy that you are looking to improve Bruno. Below are the guidelines to run Bruno on your computer.
### Technology Stack
Bruno is built using Next.js and React. We also use electron to ship a desktop version (that supports local collections)
Bruno is built using React and Electron.
Libraries we use
@@ -42,30 +42,49 @@ Libraries we use
## Development
Bruno is being developed as a desktop app. You need to load the app by running the Next.js app in one terminal and then run the electron app in another terminal.
Bruno is a desktop app. Below are the instructions to run Bruno.
> Note: We use React for the frontend and rsbuild for build and dev server.
## Install Dependencies
```bash
# use nodejs 20 version
# use nodejs 22 version
nvm use
# install deps
npm i --legacy-peer-deps
```
### Local Development (Option 1)
### Local Development
```bash
#### Build packages
##### Option 1
```bash
# build packages
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# bundle js sandbox libraries
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
```
##### Option 2
```bash
# install dependencies and setup
npm run setup
```
#### Run the app
##### Option 1
```bash
# run react app (terminal 1)
npm run dev:web
@@ -73,16 +92,22 @@ npm run dev:web
npm run dev:electron
```
### Local Development (Option 2)
##### Option 2
```bash
# install dependencies and setup
npm run setup
# run electron and react app concurrently
npm run dev
```
#### Customize Electron `userData` path
If `ELECTRON_APP_NAME` env-variable is present and its development mode, then the `appName` and `userData` path is modified accordingly.
e.g.
```sh
ELECTRON_APP_NAME=bruno-dev npm run dev:electron
```
> This doesn't change the name of the window or the names in lot of other places, only the name used by Electron internally.
### Troubleshooting
You might encounter a `Unsupported platform` error when you run `npm install`. To fix this, you will need to delete `node_modules` and `package-lock.json` and run `npm install`. This should install all the necessary packages needed to run the app.
@@ -101,7 +126,28 @@ find . -type f -name "package-lock.json" -delete
```bash
# run bruno-schema tests
npm test --workspace=packages/bruno-schema
npm run test --workspace=packages/bruno-schema
# run bruno-query tests
npm run test --workspace=packages/bruno-query
# run bruno-common tests
npm run test --workspace=packages/bruno-common
# run bruno-converters tests
npm run test --workspace=packages/bruno-converters
# run bruno-app tests
npm run test --workspace=packages/bruno-app
# run bruno-electron tests
npm run test --workspace=packages/bruno-electron
# run bruno-lang tests
npm run test --workspace=packages/bruno-lang
# run bruno-toml tests
npm run test --workspace=packages/bruno-toml
# run tests over all workspaces
npm test --workspaces --if-present

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# Next.js ऐप चलाएँ (टर्मिनल 1 पर)
npm run dev:web

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# run next app (terminal 1)
npm run dev:web

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# next 앱 실행 (1번 터미널)
npm run dev:web

View File

@@ -40,6 +40,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# draai next app (terminal 1)
npm run dev:web

View File

@@ -42,6 +42,8 @@ npm i --legacy-peer-deps
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# spustite ďalšiu aplikáciu (terminál 1)
npm run dev:web

View File

@@ -0,0 +1,27 @@
import { test, expect } from '../../playwright';
test('Should verify all support links with correct URL in preference > Support tab', async ({ page }) => {
// Open Preferences
await page.getByLabel('Open Preferences').click();
// Verify Support tab
await page.getByRole('tab', { name: 'Support' }).click();
const locator_twitter = page.getByRole('link', { name: 'Twitter' });
expect(await locator_twitter.getAttribute('href')).toEqual('https://twitter.com/use_bruno');
const locator_github = page.getByRole('link', { name: 'GitHub', exact: true });
expect(await locator_github.getAttribute('href')).toEqual('https://github.com/usebruno/bruno');
const locator_discord = page.getByRole('link', { name: 'Discord', exact: true });
expect(await locator_discord.getAttribute('href')).toEqual('https://discord.com/invite/KgcZUncpjq');
const locator_reportissues = page.getByRole('link', { name: 'Report Issues', exact: true });
expect(await locator_reportissues.getAttribute('href')).toEqual('https://github.com/usebruno/bruno/issues');
const locator_documentation = page.getByRole('link', { name: 'Documentation', exact: true });
expect(await locator_documentation.getAttribute('href')).toEqual('https://docs.usebruno.com');
});

View File

@@ -0,0 +1,5 @@
import { test, expect } from '../playwright';
test('test-app-start', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

41
eslint.config.js Normal file
View File

@@ -0,0 +1,41 @@
// eslint.config.js
const { defineConfig } = require("eslint/config");
const globals = require("globals");
module.exports = defineConfig([
{
files: ["packages/bruno-app/**/*.{js,jsx,ts}"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.browser,
...globals.jest,
global: false,
require: false,
Buffer: false,
process: false
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-electron/**/*.{js}"],
ignores: ["**/*.config.js"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
},
rules: {
"no-undef": "error",
},
}
]);

1795
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,17 @@
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@playwright/test": "^1.27.1",
"@playwright/test": "^1.51.1",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"concurrently": "^8.2.2",
"eslint": "^9.26.0",
"fs-extra": "^11.1.1",
"husky": "^8.0.3",
"globals": "^16.1.0",
"jest": "^29.2.0",
"lodash-es": "^4.17.21",
"playwright": "^1.51.1",
"pretty-quick": "^3.1.3",
"randomstring": "^1.2.2",
"rimraf": "^6.0.1",
@@ -55,11 +58,10 @@
"build:electron:rpm": "./scripts/build-electron.sh rpm",
"build:electron:snap": "./scripts/build-electron.sh snap",
"watch:common": "npm run watch --workspace=packages/bruno-common",
"test:codegen": "npm run dev:web & node ./scripts/playwright-codegen.js",
"test:e2e": "npx playwright test",
"test:report": "npx playwright show-report",
"test:codegen": "node playwright/codegen.ts",
"test:e2e": "playwright test",
"test:prettier:web": "npm run test:prettier --workspace=packages/bruno-app",
"prepare": "husky install"
"lint": "node --max_old_space_size=4096 $(npx which eslint)"
},
"overrides": {
"rollup": "3.29.5",
@@ -69,4 +71,4 @@
}
}
}
}
}

View File

@@ -19,7 +19,7 @@ export default defineConfig({
})
],
source: {
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
},
html: {
title: 'Bruno'
@@ -34,6 +34,16 @@ export default defineConfig({
},
},
},
ignoreWarnings: [
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')
],
// Add externals configuration to exclude Node.js libraries
externals: {
// List specific Node.js modules you want to exclude
// Format: 'module-name': 'commonjs module-name'
'worker_threads': 'commonjs worker_threads',
// 'path': 'commonjs path'
}
},
}
});

View File

@@ -58,6 +58,7 @@ if (!SERVER_RENDERED) {
'req.getTimeout()',
'req.setTimeout(timeout)',
'req.getExecutionMode()',
'req.getName()',
'bru',
'bru.cwd()',
'bru.getEnvName()',
@@ -80,12 +81,14 @@ if (!SERVER_RENDERED) {
'bru.getAssertionResults()',
'bru.getTestResults()',
'bru.sleep(ms)',
'bru.getCollectionName()',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()'
'bru.runner.stopExecution()',
'bru.interpolate(str)'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
@@ -363,7 +366,7 @@ export default class CodeEditor extends React.Component {
let variables = getAllVariables(this.props.collection, this.props.item);
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, mode);
defineCodeMirrorBrunoVariablesMode(variables, mode, false, this.props.enableVariableHighlighting);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -72,7 +72,7 @@ const Info = ({ collection }) => {
</div>
</div>
</div>
{showShareCollectionModal && <ShareCollection collection={collection} onClose={handleToggleShowShareCollectionModal(false)} />}
{showShareCollectionModal && <ShareCollection collectionUid={collection.uid} onClose={handleToggleShowShareCollectionModal(false)} />}
</div>
</div>
</div>

View File

@@ -49,7 +49,7 @@ const CollectionSettings = ({ collection }) => {
const requestVars = get(collection, 'root.request.vars.req', []);
const responseVars = get(collection, 'root.request.vars.res', []);
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(collection, 'root.request.auth', {}).mode;
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
@@ -155,7 +155,7 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{auth !== 'none' && <ContentIndicator />}
{authMode !== 'none' && <ContentIndicator />}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script

View File

@@ -2,7 +2,7 @@ import React, { useRef, useEffect } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash, IconAlertCircle, IconDeviceFloppy, IconRefresh, IconCircleCheck } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { selectEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
@@ -13,11 +13,18 @@ import { variableNameRegex } from 'utils/common/regex';
import { saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
const EnvironmentVariables = ({ environment, collection, setIsModified, originalEnvironmentVariables, onClose }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const addButtonRef = useRef(null);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
let _collection = cloneDeep(collection);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
_collection.globalEnvironmentVariables = globalEnvironmentVariables;
const formik = useFormik({
enableReinitialize: true,
@@ -160,7 +167,7 @@ const EnvironmentVariables = ({ environment, collection, setIsModified, original
<div className="overflow-hidden grow w-full relative">
<SingleLineEditor
theme={storedTheme}
collection={collection}
collection={_collection}
name={`${index}.value`}
value={variable.value}
isSecret={variable.secret}

View File

@@ -11,6 +11,12 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.inherit-mode-text {
color: ${(props) => props.theme.colors.text.yellow};
}
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default Wrapper;

View File

@@ -9,6 +9,14 @@ import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/Passwo
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
import AuthMode from '../AuthMode';
import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -37,12 +45,132 @@ const Auth = ({ collection, folder }) => {
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const getTreePathFromCollectionToFolder = (collection, _folder) => {
let path = [];
let item = findItemInCollection(collection, _folder?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Get path from collection to current folder
const folderTreePath = getTreePathFromCollectionToFolder(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 folderAuth = get(parentFolder, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: parentFolder.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const handleSave = () => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return (
<BasicAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'bearer': {
return (
<BearerAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'digest': {
return (
<DigestAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'ntlm': {
return (
<NTLMAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'wsse': {
return (
<WsseAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'apikey': {
return (
<ApiKeyAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'awsv4': {
return (
<AwsV4Auth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'oauth2': {
return (
<>
@@ -56,6 +184,17 @@ 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>
</>
);
}
case 'none': {
return null;
}
@@ -64,6 +203,7 @@ const Auth = ({ collection, folder }) => {
}
};
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">

View File

@@ -35,6 +35,51 @@ const AuthMode = ({ collection, folder }) => {
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('ntlm');
}}
>
NTLM Auth
</div>
<div
className="dropdown-item"
onClick={() => {
@@ -44,6 +89,33 @@ const AuthMode = ({ collection, folder }) => {
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('inherit');
}}
>
Inherit
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@@ -28,7 +28,7 @@ const FolderSettings = ({ collection, folder }) => {
tab = folderLevelSettingsSelectedTab[folder?.uid];
}
const folderRoot = collection?.items.find((item) => item.uid === folder?.uid)?.root;
const folderRoot = folder?.root;
const hasScripts = folderRoot?.request?.script?.res || folderRoot?.request?.script?.req;
const hasTests = folderRoot?.request?.tests;

View File

@@ -130,7 +130,7 @@ class MultiLineEditor extends Component {
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -5,21 +5,23 @@ import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
const ApiKeyAuth = ({ item, collection }) => {
const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {});
const apikeyAuth = get(request, 'auth.apikey', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const Icon = forwardRef((props, ref) => {
return (
@@ -90,7 +92,7 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'header');
}}
>
@@ -99,11 +101,11 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'queryparams');
}}
>
Query Params
Query Param
</div>
</Dropdown>
</div>

View File

@@ -8,14 +8,17 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import { update } from 'lodash';
const AwsV4Auth = ({ onTokenChange, item, collection }) => {
const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {});
const awsv4Auth = get(request, 'auth.awsv4', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleAccessKeyIdChange = (accessKeyId) => {
dispatch(

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BasicAuth = ({ item, collection }) => {
const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
const basicAuth = get(request, 'auth.basic', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -7,16 +7,18 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BearerAuth = ({ item, collection }) => {
const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const bearerToken = item.draft
? get(item, 'draft.request.auth.bearer.token', '')
: get(item, 'request.auth.bearer.token', '');
// Use the request prop directly like OAuth2ClientCredentials does
const bearerToken = get(request, 'auth.bearer.token', '');
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleTokenChange = (token) => {
dispatch(

View File

@@ -3,18 +3,20 @@ import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const DigestAuth = ({ item, collection }) => {
const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
const digestAuth = get(request, 'auth.digest', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const NTLMAuth = ({ item, collection }) => {
const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const ntlmAuth = item.draft ? get(item, 'draft.request.auth.ntlm', {}) : get(item, 'request.auth.ntlm', {});
const ntlmAuth = get(request, 'auth.ntlm', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(
@@ -26,7 +29,6 @@ const NTLMAuth = ({ item, collection }) => {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
}
})
);

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo } from "react";
import { find } from "lodash";
import StyledWrapper from "./StyledWrapper";
import { useState, useEffect } from "react";
import { IconChevronDown, IconChevronRight, IconCopy, IconCheck } from '@tabler/icons';
import { getAllVariables } from 'utils/collections/index';
import { interpolate } from '@usebruno/common';

View File

@@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ item, collection }) => {
const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
const wsseAuth = get(request, 'auth.wsse', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUserChange = (username) => {
dispatch(
@@ -55,6 +58,7 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handleUserChange(val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
@@ -67,6 +71,8 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
/>
</div>
</StyledWrapper>

View File

@@ -7,6 +7,8 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
@@ -27,6 +29,16 @@ const getTreePathFromCollectionToItem = (collection, _item) => {
const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
// Create a request object to pass to the auth components
const request = item.draft
? get(item, 'draft.request', {})
: get(item, 'request', {});
// Save function for request level
const save = () => {
return saveRequest(item.uid, collection.uid);
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
@@ -59,28 +71,28 @@ const Auth = ({ item, collection }) => {
const getAuthView = () => {
switch (authMode) {
case 'awsv4': {
return <AwsV4Auth collection={collection} item={item} />;
return <AwsV4Auth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'basic': {
return <BasicAuth collection={collection} item={item} />;
return <BasicAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} />;
return <BearerAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'digest': {
return <DigestAuth collection={collection} item={item} />;
return <DigestAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'ntlm': {
return <NTLMAuth collection={collection} item={item} />;
return <NTLMAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} />;
return <OAuth2 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'wsse': {
return <WsseAuth collection={collection} item={item} />;
return <WsseAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} />;
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();

View File

@@ -64,9 +64,10 @@ const GraphQLVariables = ({ variables, item, collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="javascript"
mode="application/json"
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
/>
</>
);

View File

@@ -140,7 +140,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
</div>
</div>
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem collectionUid={collection.uid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
</StyledWrapper>
);

View File

@@ -49,7 +49,7 @@ const RequestBody = ({ item, collection }) => {
<StyledWrapper className="w-full">
<CodeEditor
collection={collection}
item={item}
item={item}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
@@ -58,13 +58,14 @@ const RequestBody = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
mode={codeMirrorMode[bodyMode]}
enableVariableHighlighting={true}
/>
</StyledWrapper>
);
}
if (bodyMode === 'file') {
return <FileBody item={item} collection={collection}/>
return <FileBody item={item} collection={collection} />;
}
if (bodyMode === 'formUrlEncoded') {
@@ -77,4 +78,4 @@ const RequestBody = ({ item, collection }) => {
return <StyledWrapper className="w-full">No Body</StyledWrapper>;
};
export default RequestBody;
export default RequestBody;

View File

@@ -0,0 +1,42 @@
import React, { useEffect, useState, useCallback } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
const FolderNotFound = ({ folderUid }) => {
const dispatch = useDispatch();
const [showErrorMessage, setShowErrorMessage] = useState(false);
const closeTab = useCallback(() => {
dispatch(
closeTabs({
tabUids: [folderUid]
})
);
}, [dispatch, folderUid]);
useEffect(() => {
setTimeout(() => {
setShowErrorMessage(true);
}, 300);
}, []);
if (!showErrorMessage) {
return null;
}
return (
<div className="mt-6 px-6">
<div className="p-4 bg-orange-100 border-l-4 border-yellow-500 text-yellow-700">
<div>Folder no longer exists.</div>
<div className="mt-2">
This can happen when the folder was renamed or deleted on your filesystem.
</div>
</div>
<button className="btn btn-md btn-secondary mt-6" onClick={closeTab}>
Close Tab
</button>
</div>
);
};
export default FolderNotFound;

View File

@@ -25,7 +25,7 @@ import { produce } from 'immer';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import FolderNotFound from './FolderNotFound';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 350;
@@ -165,11 +165,7 @@ const RequestTabPanel = () => {
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
if (!folder) {
dispatch(
closeTabs({
tabUids: [activeTabUid]
})
);
return <FolderNotFound folderUid={focusedTab.folderUid} />;
}
return <FolderSettings collection={collection} folder={folder} />;

View File

@@ -76,7 +76,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
className={`flex items-center justify-between tab-container px-1 ${tab.preview ? "italic" : ""}`}
onMouseUp={handleMouseUp} // Add middle-click behavior here
>
{tab.type === 'folder-settings' ? (
{tab.type === 'folder-settings' && !folder ? (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
) : tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
@@ -261,13 +263,13 @@ function RequestTabMenu({ onDropdownCreate, collectionRequestTabs, tabIndex, col
return (
<Fragment>
{showAddNewRequestModal && (
<NewRequest collection={collection} onClose={() => setShowAddNewRequestModal(false)} />
<NewRequest collectionUid={collection.uid} onClose={() => setShowAddNewRequestModal(false)} />
)}
{showCloneRequestModal && (
<CloneCollectionItem
item={currentTabItem}
collection={collection}
collectionUid={collection.uid}
onClose={() => setShowCloneRequestModal(false)}
/>
)}

View File

@@ -79,7 +79,7 @@ const RequestTabs = () => {
return (
<StyledWrapper className={getRootClassname()}>
{newRequestModalOpen && (
<NewRequest collection={activeCollection} onClose={() => setNewRequestModalOpen(false)} />
<NewRequest collectionUid={activeCollection?.uid} onClose={() => setNewRequestModalOpen(false)} />
)}
{collectionRequestTabs && collectionRequestTabs.length ? (
<>

View File

@@ -17,7 +17,7 @@ const ResponseLoadingOverlay = ({ item, collection }) => {
<div className="overlay">
<div style={{ marginBottom: 15, fontSize: 26 }}>
<div style={{ display: 'inline-block', fontSize: 20, marginLeft: 5, marginRight: 5 }}>
<StopWatch requestTimestamp={item?.requestSent?.timestamp} />
<StopWatch startTime={item?.requestStartTime} />
</div>
</div>
<IconRefresh size={24} className="loading-icon" />

View File

@@ -2,14 +2,20 @@ import React from 'react';
import StyledWrapper from './StyledWrapper';
const ResponseSize = ({ size }) => {
if (!Number.isFinite(size)) {
return null;
}
let sizeToDisplay = '';
// If size is greater than 1024 bytes, format as KB
if (size > 1024) {
// size is greater than 1kb
let kb = Math.floor(size / 1024);
let decimal = Math.round(((size % 1024) / 1024).toFixed(2) * 100);
sizeToDisplay = kb + '.' + decimal + 'KB';
} else {
// If size is less than or equal to 1024 bytes, display as bytes (B)
sizeToDisplay = size + 'B';
}

View File

@@ -48,6 +48,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
};
const response = item.response || {};
const responseSize = response.size || 0;
const getTabPanel = (tab) => {
switch (tab) {
@@ -156,7 +157,7 @@ const ResponsePane = ({ rightPaneWidth, item, collection }) => {
<ResponseSave item={item} />
<StatusCode status={response.status} />
<ResponseTime duration={response.duration} />
<ResponseSize size={response.size} />
<ResponseSize size={responseSize} />
</>
) : null}
</div>

View File

@@ -7,8 +7,11 @@ import exportBrunoCollection from 'utils/collections/export';
import exportPostmanCollection from 'utils/exporters/postman-collection';
import { cloneDeep } from 'lodash';
import { transformCollectionToSaveToExportAsFile } from 'utils/collections/index';
import { useSelector } from 'react-redux';
import { findCollectionByUid } from 'utils/collections/index';
const ShareCollection = ({ onClose, collection }) => {
const ShareCollection = ({ onClose, collectionUid }) => {
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const handleExportBrunoCollection = () => {
const collectionCopy = cloneDeep(collection);
exportBrunoCollection(transformCollectionToSaveToExportAsFile(collectionCopy));

View File

@@ -1,5 +1,5 @@
import React, { useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
@@ -11,11 +11,13 @@ import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from "@tabler/icons";
import { findCollectionByUid } from 'utils/collections/index';
const CloneCollection = ({ onClose, collection }) => {
const CloneCollection = ({ onClose, collectionUid }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const [isEditing, toggleEditing] = useState(false);
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const { name } = collection;
const formik = useFormik({
@@ -46,7 +48,7 @@ const CloneCollection = ({ onClose, collection }) => {
values.collectionName,
values.collectionFolderName,
values.collectionLocation,
collection.pathname
collection?.pathname
)
)
.then(() => {

View File

@@ -15,7 +15,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const CloneCollectionItem = ({ collection, item, onClose }) => {
const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
@@ -49,7 +49,7 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
.test('not-reserved', `The file names "collection" and "folder" are reserved in bruno`, value => !['collection', 'folder'].includes(value))
}),
onSubmit: (values) => {
dispatch(cloneItem(values.name, values.filename, item.uid, collection.uid))
dispatch(cloneItem(values.name, values.filename, item.uid, collectionUid))
.then(() => {
toast.success('Request cloned!');
onClose();
@@ -172,8 +172,6 @@ const CloneCollectionItem = ({ collection, item, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
baseName={formik.values.filename}
/>
</div>

View File

@@ -0,0 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.drag-preview {
background-color: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,49 @@
import { useDragLayer } from 'react-dnd';
import {
IconFile,
IconFolder,
} from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
function getItemStyles({ x, y }) {
if (Number.isNaN(x) || Number.isNaN(y)) return { display: 'none' };
const transform = `translate(${x}px, ${y}px)`;
return {
position: 'fixed',
pointerEvents: 'none',
top: 0,
transform,
WebkitTransform: transform,
zIndex: 100,
};
}
export const CollectionItemDragPreview = () => {
const {
item,
isDragging,
clientOffset
} = useDragLayer((monitor) => ({
item: monitor.getItem(),
isDragging: monitor.isDragging(),
clientOffset: monitor.getClientOffset(),
}));
if (!isDragging) return null;
const { x, y } = clientOffset || {};
const shouldShowFolderIcon = !item.type || item.type === 'folder';
return (
<StyledWrapper>
<div style={getItemStyles({ x, y })} className='p-2'>
<div className='flex items-center gap-2 border border-gray-500/10 rounded-md px-2 py-1 drag-preview'>
{shouldShowFolderIcon ? (
<IconFolder size={16} />
) : (
<IconFile size={16} />
)}
{item.name}
</div>
</div>
</StyledWrapper>
);
};

View File

@@ -7,11 +7,11 @@ import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
const DeleteCollectionItem = ({ onClose, item, collection }) => {
const DeleteCollectionItem = ({ onClose, item, collectionUid }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const onConfirm = () => {
dispatch(deleteItem(item.uid, collection.uid)).then(() => {
dispatch(deleteItem(item.uid, collectionUid)).then(() => {
if (isFolder) {
// close all tabs that belong to the folder

View File

@@ -62,6 +62,7 @@ const CodeView = ({ language, item }) => {
<CodeEditor
readOnly
collection={collection}
item={item}
value={snippet}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}

View File

@@ -4,15 +4,65 @@ import CodeView from './CodeView';
import StyledWrapper from './StyledWrapper';
import { isValidUrl } from 'utils/url';
import { get } from 'lodash';
import { findEnvironmentInCollection } from 'utils/collections';
import { findEnvironmentInCollection, findItemInCollection, findParentItemInCollection } from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url/index';
import { getLanguages } from 'utils/codegenerator/targets';
import { useSelector } from 'react-redux';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
const GenerateCodeItem = ({ collection, item, onClose }) => {
const getTreePathFromCollectionToItem = (collection, _itemUid) => {
let path = [];
let item = findItemInCollection(collection, _itemUid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
// Function to resolve inherited auth
const resolveInheritedAuth = (item, collection) => {
const request = item.draft?.request || item.request;
const authMode = request?.auth?.mode;
// If auth is not inherit or no auth defined, return the request as is
if (!authMode || authMode !== 'inherit') {
return {
...request
};
}
// Get the tree path from collection to item
const requestTreePath = getTreePathFromCollectionToItem(collection, item.uid);
// Default to collection auth
const collectionAuth = get(collection, 'root.request.auth', { mode: 'none' });
let effectiveAuth = collectionAuth;
let source = 'collection';
// Check folders in reverse to find the closest auth configuration
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
effectiveAuth = folderAuth;
source = 'folder';
break;
}
}
}
return {
...request,
auth: effectiveAuth
};
};
const GenerateCodeItem = ({ collectionUid, item, onClose }) => {
const languages = getLanguages();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const globalEnvironmentVariables = getGlobalEnvironmentVariables({ globalEnvironments, activeGlobalEnvironmentUid });
@@ -44,6 +94,9 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
get(item, 'draft.request.params') !== undefined ? get(item, 'draft.request.params') : get(item, 'request.params')
);
// Resolve auth inheritance
const resolvedRequest = resolveInheritedAuth(item, collection);
const [selectedLanguage, setSelectedLanguage] = useState(languages[0]);
return (
<Modal size="lg" title="Generate Code" handleCancel={onClose} hideFooter={true}>
@@ -92,16 +145,10 @@ const GenerateCodeItem = ({ collection, item, onClose }) => {
language={selectedLanguage}
item={{
...item,
request:
item.request.url !== ''
? {
...item.request,
url: finalUrl
}
: {
...item.draft.request,
url: finalUrl
}
request: {
...resolvedRequest,
url: finalUrl
}
}}
/>
) : (

View File

@@ -16,7 +16,7 @@ import Portal from 'components/Portal';
import Dropdown from 'components/Dropdown';
import StyledWrapper from './StyledWrapper';
const RenameCollectionItem = ({ collection, item, onClose }) => {
const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const isFolder = isItemAFolder(item);
const inputRef = useRef();
@@ -57,13 +57,13 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
return;
}
if (!isFolder && item.draft) {
await dispatch(saveRequest(item.uid, collection.uid, true));
await dispatch(saveRequest(item.uid, collectionUid, true));
}
const { name: newName, filename: newFilename } = values;
try {
let renameConfig = {
itemUid: item.uid,
collectionUid: collection.uid,
collectionUid,
};
renameConfig['newName'] = newName;
if (itemFilename !== newFilename) {
@@ -191,8 +191,6 @@ const RenameCollectionItem = ({ collection, item, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, path.dirname(item?.pathname))}
baseName={formik.values.filename}
/>
</div>

View File

@@ -2,16 +2,19 @@ import React from 'react';
import get from 'lodash/get';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { runCollectionFolder } from 'providers/ReduxStore/slices/collections/actions';
import { flattenItems } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import { areItemsLoading } from 'utils/collections';
const RunCollectionItem = ({ collection, item, onClose }) => {
const RunCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const isCollectionRunInProgress = collection?.runnerResult?.info?.status && (collection?.runnerResult?.info?.status !== 'ended');
const onSubmit = (recursive) => {
dispatch(
addTab({
@@ -20,10 +23,24 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
type: 'collection-runner'
})
);
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
if (!isCollectionRunInProgress) {
dispatch(runCollectionFolder(collection.uid, item ? item.uid : null, recursive));
}
onClose();
};
const handleViewRunner = (e) => {
e.preventDefault();
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-runner'
})
);
onClose();
}
const getRequestsCount = (items) => {
const requestTypes = ['http-request', 'graphql-request']
return items.filter(req => requestTypes.includes(req.type)).length;
@@ -34,8 +51,6 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
const recursiveRunLength = getRequestsCount(flattenedItems);
const isFolderLoading = areItemsLoading(item);
console.log(item);
console.log(isFolderLoading);
return (
<StyledWrapper>
@@ -55,22 +70,34 @@ const RunCollectionItem = ({ collection, item, onClose }) => {
</div>
<div className={isFolderLoading ? "mb-2" : "mb-8"}>This will run all the requests in this folder and all its subfolders.</div>
{isFolderLoading ? <div className='mb-8 warning'>Requests in this folder are still loading.</div> : null}
{isCollectionRunInProgress ? <div className='mb-6 warning'>A Collection Run is already in progress.</div> : null}
<div className="flex justify-end bruno-modal-footer">
<span className="mr-3">
<button type="button" onClick={onClose} className="btn btn-md btn-close">
Cancel
</button>
</span>
<span>
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run
</button>
</span>
<span>
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run
</button>
</span>
{
isCollectionRunInProgress ?
<span>
<button type="submit" className="submit btn btn-md btn-secondary mr-3" onClick={handleViewRunner}>
View Run
</button>
</span>
:
<>
<span>
<button type="submit" disabled={!recursiveRunLength} className="submit btn btn-md btn-secondary mr-3" onClick={() => onSubmit(true)}>
Recursive Run
</button>
</span>
<span>
<button type="submit" disabled={!runLength} className="submit btn btn-md btn-secondary" onClick={() => onSubmit(false)}>
Run
</button>
</span>
</>
}
</div>
</div>
)}

View File

@@ -1,6 +1,7 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
.menu-icon {
color: ${(props) => props.theme.sidebar.dropdownIcon.color};
@@ -22,6 +23,65 @@ const Wrapper = styled.div`
height: 1.875rem;
cursor: pointer;
user-select: none;
position: relative;
/* Common styles for drop indicators */
&::before,
&::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
background: ${(props) => props.theme.dragAndDrop.border};
opacity: 0;
pointer-events: none;
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
/* Drop target styles */
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
&::before,
&::after {
opacity: 0;
}
}
&.drop-target-above {
&::before {
opacity: 1;
height: 2px;
}
}
&.drop-target-below {
&::after {
opacity: 1;
height: 2px;
}
}
/* Inside drop target style */
&.drop-target {
&::before {
top: 0;
bottom: 0;
height: 100%;
opacity: 1;
background: ${(props) => props.theme.dragAndDrop.hoverBg};
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
// border-radius: 4px;
}
}
.rotate-90 {
transform: rotateZ(90deg);
@@ -45,6 +105,20 @@ const Wrapper = styled.div`
}
}
&.item-target {
background: #ccc3;
}
&.item-seperator {
.seperator {
bottom: 0px;
position: absolute;
height: 3px;
width: 100%;
background: #ccc3;
}
}
&.item-focused-in-tab {
background: ${(props) => props.theme.sidebar.collection.item.bg};

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef, forwardRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
import classnames from 'classnames';
@@ -6,7 +7,7 @@ import { useDrag, useDrop } from 'react-dnd';
import { IconChevronRight, IconDots } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { moveItem, showInFolder, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { handleCollectionItemDrop, sendRequest, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { collectionFolderClicked } from 'providers/ReduxStore/slices/collections';
import Dropdown from 'components/Dropdown';
import NewRequest from 'components/Sidebar/NewRequest';
@@ -16,7 +17,7 @@ import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
import RunCollectionItem from './RunCollectionItem';
import GenerateCodeItem from './GenerateCodeItem';
import { isItemARequest, isItemAFolder, itemIsOpenedInTabs } from 'utils/tabs';
import { isItemARequest, isItemAFolder } from 'utils/tabs';
import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from 'utils/collections/search';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { hideHomePage } from 'providers/ReduxStore/slices/app';
@@ -26,13 +27,22 @@ import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
import { isEqual } from 'lodash';
import { calculateDraggedItemNewPathname } from 'utils/collections/index';
const CollectionItem = ({ item, collection, searchText }) => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) => {
const _isTabForItemActiveSelector = isTabForItemActiveSelector({ itemUid: item.uid });
const isTabForItemActive = useSelector(_isTabForItemActiveSelector, isEqual);
const _isTabForItemPresentSelector = isTabForItemPresentSelector({ itemUid: item.uid });
const isTabForItemPresent = useSelector(_isTabForItemPresentSelector, isEqual);
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const dispatch = useDispatch();
const collectionItemRef = useRef(null);
// We use a single ref for drag and drop.
const ref = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
@@ -44,10 +54,13 @@ const CollectionItem = ({ item, collection, searchText }) => {
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
const hasSearchText = searchText && searchText?.trim()?.length;
const itemIsCollapsed = hasSearchText ? false : item.collapsed;
const isFolder = isItemAFolder(item);
const [{ isDragging }, drag] = useDrag({
type: `collection-item-${collection.uid}`,
item: item,
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
const [{ isDragging }, drag, dragPreview] = useDrag({
type: `collection-item-${collectionUid}`,
item,
collect: (monitor) => ({
isDragging: monitor.isDragging()
}),
@@ -56,21 +69,72 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
});
const [{ isOver }, drop] = useDrop({
accept: `collection-item-${collection.uid}`,
drop: (draggedItem) => {
dispatch(moveItem(collection.uid, draggedItem.uid, item.uid));
useEffect(() => {
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);
const determineDropType = (monitor) => {
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!hoverBoundingRect || !clientOffset) return null;
const clientY = clientOffset.y - hoverBoundingRect.top;
const folderUpperThreshold = hoverBoundingRect.height * 0.35;
const fileUpperThreshold = hoverBoundingRect.height * 0.5;
if (isItemAFolder(item)) {
return clientY < folderUpperThreshold ? 'adjacent' : 'inside';
} else {
return clientY < fileUpperThreshold ? 'adjacent' : null;
}
};
const canItemBeDropped = ({ draggedItem, targetItem, dropType }) => {
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
if (draggedItemUid === targetItemUid) return false;
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname });
if (!newPathname) return false;
if (targetItemPathname?.startsWith(draggedItemPathname)) return false;
return true;
};
const [{ isOver, canDrop }, drop] = useDrop({
accept: `collection-item-${collectionUid}`,
hover: (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
const _canItemBeDropped = canItemBeDropped({ draggedItem, targetItem: item, dropType });
setDropType(_canItemBeDropped ? dropType : null);
},
canDrop: (draggedItem) => {
return draggedItem.uid !== item.uid;
drop: async (draggedItem, monitor) => {
const { uid: targetItemUid } = item;
const { uid: draggedItemUid } = draggedItem;
if (draggedItemUid === targetItemUid) return;
const dropType = determineDropType(monitor);
if (!dropType) return;
await dispatch(handleCollectionItemDrop({ targetItem: item, draggedItem, dropType, collectionUid }))
setDropType(null);
},
canDrop: (draggedItem) => draggedItem.uid !== item.uid,
collect: (monitor) => ({
isOver: monitor.isOver(),
isOver: monitor.isOver()
}),
});
drag(drop(collectionItemRef));
const dropdownTippyRef = useRef();
const MenuIcon = forwardRef((props, ref) => {
return (
@@ -84,13 +148,15 @@ const CollectionItem = ({ item, collection, searchText }) => {
'rotate-90': !itemIsCollapsed
});
const itemRowClassName = classnames('flex collection-item-name items-center', {
'item-focused-in-tab': item.uid == activeTabUid,
'item-hovered': isOver
const itemRowClassName = classnames('flex collection-item-name relative items-center', {
'item-focused-in-tab': isTabForItemActive,
'item-hovered': isOver && canDrop,
'drop-target': isOver && dropType === 'inside',
'drop-target-above': isOver && dropType === 'adjacent'
});
const handleRun = async () => {
dispatch(sendRequest(item, collection.uid)).catch((err) =>
dispatch(sendRequest(item, collectionUid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
})
@@ -101,12 +167,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
if (event && event.detail != 1) return;
//scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
const isRequest = isItemARequest(item);
if (isRequest) {
dispatch(hideHomePage());
if (itemIsOpenedInTabs(item, tabs)) {
if (isTabForItemPresent) {
dispatch(
focusTab({
uid: item.uid
@@ -114,11 +178,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab(item),
type: 'request',
})
@@ -127,14 +190,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
type: 'folder-settings',
})
);
dispatch(
collectionFolderClicked({
itemUid: item.uid,
collectionUid: collection.uid
collectionUid: collectionUid
})
);
}
@@ -146,10 +209,10 @@ const CollectionItem = ({ item, collection, searchText }) => {
dispatch(
collectionFolderClicked({
itemUid: item.uid,
collectionUid: collection.uid
collectionUid: collectionUid
})
);
}
};
const handleRightClick = (event) => {
const _menuDropdown = dropdownTippyRef.current;
@@ -164,7 +227,6 @@ const CollectionItem = ({ item, collection, searchText }) => {
let indents = range(item.depth);
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const isFolder = isItemAFolder(item);
const className = classnames('flex flex-col w-full', {
'is-sidebar-dragging': isSidebarDragging
@@ -183,49 +245,14 @@ const CollectionItem = ({ item, collection, searchText }) => {
}
const handleDoubleClick = (event) => {
dispatch(makeTabPermanent({ uid: item.uid }))
dispatch(makeTabPermanent({ uid: item.uid }));
};
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
// Sort items by their "seq" property.
const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
// we need to sort folder items by name alphabetically
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (item?.request?.url !== '' || (item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (itemIsOpenedInTabs(item, tabs)) {
dispatch(
focusTab({
uid: item.uid
})
);
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
type: 'folder-settings'
})
);
return;
}
};
const handleShowInFolder = () => {
dispatch(showInFolder(item.pathname)).catch((error) => {
console.error('Error opening the folder', error);
@@ -233,62 +260,89 @@ const CollectionItem = ({ item, collection, searchText }) => {
});
};
const requestItems = sortRequestItems(filter(item.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(item.items, (i) => isItemAFolder(i)));
const folderItems = sortItemsBySequence(filter(item.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i)));
const handleGenerateCode = (e) => {
e.stopPropagation();
dropdownTippyRef.current.hide();
if (
(item?.request?.url !== '') ||
(item?.draft?.request?.url !== undefined && item?.draft?.request?.url !== '')
) {
setGenerateCodeItemModalOpen(true);
} else {
toast.error('URL is required');
}
};
const viewFolderSettings = () => {
if (isItemAFolder(item)) {
if (isTabForItemPresent) {
dispatch(focusTab({ uid: item.uid }));
return;
}
dispatch(
addTab({
uid: item.uid,
collectionUid,
type: 'folder-settings'
})
);
}
};
return (
<StyledWrapper className={className}>
{renameItemModalOpen && (
<RenameCollectionItem item={item} collection={collection} onClose={() => setRenameItemModalOpen(false)} />
<RenameCollectionItem item={item} collectionUid={collectionUid} onClose={() => setRenameItemModalOpen(false)} />
)}
{cloneItemModalOpen && (
<CloneCollectionItem item={item} collection={collection} onClose={() => setCloneItemModalOpen(false)} />
<CloneCollectionItem item={item} collectionUid={collectionUid} onClose={() => setCloneItemModalOpen(false)} />
)}
{deleteItemModalOpen && (
<DeleteCollectionItem item={item} collection={collection} onClose={() => setDeleteItemModalOpen(false)} />
<DeleteCollectionItem item={item} collectionUid={collectionUid} onClose={() => setDeleteItemModalOpen(false)} />
)}
{newRequestModalOpen && (
<NewRequest item={item} collection={collection} onClose={() => setNewRequestModalOpen(false)} />
<NewRequest item={item} collectionUid={collectionUid} onClose={() => setNewRequestModalOpen(false)} />
)}
{newFolderModalOpen && (
<NewFolder item={item} collection={collection} onClose={() => setNewFolderModalOpen(false)} />
<NewFolder item={item} collectionUid={collectionUid} onClose={() => setNewFolderModalOpen(false)} />
)}
{runCollectionModalOpen && (
<RunCollectionItem collection={collection} item={item} onClose={() => setRunCollectionModalOpen(false)} />
<RunCollectionItem collectionUid={collectionUid} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)}
{generateCodeItemModalOpen && (
<GenerateCodeItem collection={collection} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
<GenerateCodeItem collectionUid={collectionUid} item={item} onClose={() => setGenerateCodeItemModalOpen(false)} />
)}
{itemInfoModalOpen && (
<CollectionItemInfo item={item} collection={collection} onClose={() => setItemInfoModalOpen(false)} />
<CollectionItemInfo item={item} onClose={() => setItemInfoModalOpen(false)} />
)}
<div className={itemRowClassName} ref={collectionItemRef}>
<div
className={itemRowClassName}
ref={(node) => {
ref.current = node;
drag(drop(node));
}}
>
<div className="flex items-center h-full w-full">
{indents && indents.length
? indents.map((i) => {
return (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{
width: 16,
minWidth: 16,
height: '100%'
}}
>
&nbsp;{/* Indent */}
</div>
);
})
? indents.map((i) => (
<div
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
className="indent-block"
key={i}
style={{ width: 16, minWidth: 16, height: '100%' }}
>
&nbsp;{/* Indent */}
</div>
))
: null}
<div
className="flex flex-grow items-center h-full overflow-hidden"
style={{
paddingLeft: 8
}}
style={{ paddingLeft: 8 }}
onClick={handleClick}
onContextMenu={handleRightClick}
onDoubleClick={handleDoubleClick}
@@ -304,10 +358,7 @@ const CollectionItem = ({ item, collection, searchText }) => {
/>
) : null}
</div>
<div
className="ml-1 flex w-full h-full items-center overflow-hidden"
>
<div className="ml-1 flex w-full h-full items-center overflow-hidden">
<CollectionItemIcon item={item} />
<span className="item-name" title={item.name}>
{item.name}
@@ -429,17 +480,16 @@ const CollectionItem = ({ item, collection, searchText }) => {
</div>
</div>
</div>
{!itemIsCollapsed ? (
<div>
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
})
: null}
</div>
@@ -448,4 +498,4 @@ const CollectionItem = ({ item, collection, searchText }) => {
);
};
export default CollectionItem;
export default React.memo(CollectionItem);

View File

@@ -1,12 +1,14 @@
import React from 'react';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { IconFiles } from '@tabler/icons';
import { removeCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RemoveCollection = ({ onClose, collection }) => {
const RemoveCollection = ({ onClose, collectionUid }) => {
const dispatch = useDispatch();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const onConfirm = () => {
dispatch(removeCollection(collection.uid))

View File

@@ -2,13 +2,15 @@ import React, { useRef, useEffect } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { renameCollection } from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid } from 'utils/collections/index';
const RenameCollection = ({ collection, onClose }) => {
const RenameCollection = ({ collectionUid, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const collection = useSelector(state => findCollectionByUid(state.collections.collections, collectionUid));
const formik = useFormik({
enableReinitialize: true,
initialValues: {

View File

@@ -13,7 +13,8 @@ const Wrapper = styled.div`
}
&.item-hovered {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-bottom: 2px solid transparent;
.collection-actions {
.dropdown {
div[aria-expanded='false'] {
@@ -62,6 +63,36 @@ const Wrapper = styled.div`
color: white;
}
}
&.drop-target {
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-above {
border: none;
border-top: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-top: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
&.drop-target-below {
border: none;
border-bottom: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
margin-bottom: -2px;
background: transparent;
transition: ${(props) => props.theme.dragAndDrop.transition};
}
}
.collection-name.drop-target {
border: ${(props) => props.theme.dragAndDrop.borderStyle} ${(props) => props.theme.dragAndDrop.border};
border-radius: 4px;
background-color: ${(props) => props.theme.dragAndDrop.hoverBg};
margin: -2px;
transition: ${(props) => props.theme.dragAndDrop.transition};
box-shadow: 0 0 0 2px ${(props) => props.theme.dragAndDrop.hoverBg};
}
#sidebar-collection-name {

View File

@@ -1,4 +1,5 @@
import React, { useState, forwardRef, useRef, useEffect } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import classnames from 'classnames';
import { uuid } from 'utils/common';
import filter from 'lodash/filter';
@@ -6,8 +7,8 @@ import { useDrop, useDrag } from 'react-dnd';
import { IconChevronRight, IconDots, IconLoader2 } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { collapseCollection } from 'providers/ReduxStore/slices/collections';
import { mountCollection, moveItemToRootOfCollection, moveCollectionAndPersist } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch, useSelector } from 'react-redux';
import { mountCollection, moveCollectionAndPersist, handleCollectionItemDrop } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import { addTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
@@ -19,9 +20,10 @@ import { isItemAFolder, isItemARequest } from 'utils/collections';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection';
import { areItemsLoading, findItemInCollection } from 'utils/collections';
import { areItemsLoading } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
import ShareCollection from 'components/ShareCollection/index';
import { CollectionItemDragPreview } from './CollectionItem/CollectionItemDragPreview/index';
const Collection = ({ collection, searchText }) => {
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
@@ -33,7 +35,7 @@ const Collection = ({ collection, searchText }) => {
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null);
const menuDropdownTippyRef = useRef();
const onMenuDropdownCreate = (ref) => (menuDropdownTippyRef.current = ref);
const MenuIcon = forwardRef((props, ref) => {
@@ -127,8 +129,8 @@ const Collection = ({ collection, searchText }) => {
const isCollectionItem = (itemType) => {
return itemType.startsWith('collection-item');
};
const [{ isDragging }, drag] = useDrag({
const [{ isDragging }, drag, dragPreview] = useDrag({
type: "collection",
item: collection,
collect: (monitor) => ({
@@ -144,7 +146,7 @@ const Collection = ({ collection, searchText }) => {
drop: (draggedItem, monitor) => {
const itemType = monitor.getItemType();
if (isCollectionItem(itemType)) {
dispatch(moveItemToRootOfCollection(collection.uid, draggedItem.uid))
dispatch(handleCollectionItemDrop({ targetItem: collection, draggedItem, dropType: 'inside', collectionUid: collection.uid }))
} else {
dispatch(moveCollectionAndPersist({draggedItem, targetItem: collection}));
}
@@ -157,7 +159,9 @@ const Collection = ({ collection, searchText }) => {
}),
});
drag(drop(collectionRef));
useEffect(() => {
dragPreview(getEmptyImage(), { captureDraggingState: true });
}, []);
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
@@ -170,36 +174,35 @@ const Collection = ({ collection, searchText }) => {
});
// we need to sort request items by seq property
const sortRequestItems = (items = []) => {
const sortItemsBySequence = (items = []) => {
return items.sort((a, b) => a.seq - b.seq);
};
// we need to sort folder items by name alphabetically
const sortFolderItems = (items = []) => {
return items.sort((a, b) => a.name.localeCompare(b.name));
};
const requestItems = sortRequestItems(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortFolderItems(filter(collection.items, (i) => isItemAFolder(i)));
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i)));
const folderItems = sortItemsBySequence(filter(collection.items, (i) => isItemAFolder(i)));
return (
<StyledWrapper className="flex flex-col">
{showNewRequestModal && <NewRequest collection={collection} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collection={collection} onClose={() => setShowNewFolderModal(false)} />}
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
{showRenameCollectionModal && (
<RenameCollection collection={collection} onClose={() => setShowRenameCollectionModal(false)} />
<RenameCollection collectionUid={collection.uid} onClose={() => setShowRenameCollectionModal(false)} />
)}
{showRemoveCollectionModal && (
<RemoveCollection collection={collection} onClose={() => setShowRemoveCollectionModal(false)} />
<RemoveCollection collectionUid={collection.uid} onClose={() => setShowRemoveCollectionModal(false)} />
)}
{showShareCollectionModal && (
<ShareCollection collection={collection} onClose={() => setShowShareCollectionModal(false)} />
<ShareCollection collectionUid={collection.uid} onClose={() => setShowShareCollectionModal(false)} />
)}
{showCloneCollectionModalOpen && (
<CloneCollection collection={collection} onClose={() => setShowCloneCollectionModalOpen(false)} />
<CloneCollection collectionUid={collection.uid} onClose={() => setShowCloneCollectionModalOpen(false)} />
)}
<CollectionItemDragPreview />
<div className={collectionRowClassName}
ref={collectionRef}
ref={(node) => {
collectionRef.current = node;
drag(drop(node));
}}
>
<div
className="flex flex-grow items-center overflow-hidden"
@@ -296,20 +299,15 @@ const Collection = ({ collection, searchText }) => {
</Dropdown>
</div>
</div>
<div>
{!collectionIsCollapsed ? (
<div>
{folderItems && folderItems.length
? folderItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{requestItems && requestItems.length
? requestItems.map((i) => {
return <CollectionItem key={i.uid} item={i} collection={collection} searchText={searchText} />;
})
: null}
{folderItems?.map?.((i) => {
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
})}
{requestItems?.map?.((i) => {
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
})}
</div>
) : null}
</div>

View File

@@ -11,6 +11,8 @@ import PathDisplay from 'components/PathDisplay/index';
import { useState } from 'react';
import { IconArrowBackUp, IconEdit } from '@tabler/icons';
import Help from 'components/Help';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
const CreateCollection = ({ onClose }) => {
const inputRef = useRef();
@@ -45,7 +47,7 @@ const CreateCollection = ({ onClose }) => {
toast.success('Collection created!');
onClose();
})
.catch((e) => toast.error('An error occurred while creating the collection - ' + e));
.catch((e) => toast.error(multiLineMsg('An error occurred while creating the collection', formatIpcError(e))));
}
});
@@ -113,7 +115,6 @@ const CreateCollection = ({ onClose }) => {
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
@@ -121,6 +122,9 @@ const CreateCollection = ({ onClose }) => {
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={e => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500">{formik.errors.collectionLocation}</div>

View File

@@ -1,34 +1,43 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { IconLoader2 } from '@tabler/icons';
import importBrunoCollection from 'utils/importers/bruno-collection';
import importPostmanCollection from 'utils/importers/postman-collection';
import { postmanToBruno, readFile } from 'utils/importers/postman-collection';
import importInsomniaCollection from 'utils/importers/insomnia-collection';
import importOpenapiCollection from 'utils/importers/openapi-collection';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import fileDialog from 'file-dialog';
const ImportCollection = ({ onClose, handleSubmit }) => {
const [isLoading, setIsLoading] = useState(false)
const handleImportBrunoCollection = () => {
importBrunoCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Import collection failed'));
.catch((err) => toastError(err, 'Import collection failed'))
};
const handleImportPostmanCollection = () => {
importPostmanCollection()
.then(({ collection }) => {
handleSubmit({ collection });
fileDialog({ accept: 'application/json' })
.then((...args) => {
setIsLoading(true);
return readFile(...args);
})
.catch((err) => toastError(err, 'Postman Import collection failed'));
};
.then((collection) => postmanToBruno(collection))
.then((collection) => handleSubmit({ collection }))
.catch((err) => toastError(err, 'Postman Import collection failed'))
.finally(() => setIsLoading(false));
}
const handleImportInsomniaCollection = () => {
importInsomniaCollection()
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'Insomnia Import collection failed'));
.catch((err) => toastError(err, 'Insomnia Import collection failed'))
};
const handleImportOpenapiCollection = () => {
@@ -36,8 +45,9 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
.then(({ collection }) => {
handleSubmit({ collection });
})
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'));
.catch((err) => toastError(err, 'OpenAPI v3 Import collection failed'))
};
const CollectionButton = ({ children, className, onClick }) => {
return (
<button
@@ -50,18 +60,67 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
</button>
);
};
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<h3 className="text-sm">Select the type of your existing collection :</h3>
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
const FullscreenLoader = () => {
const [loadingMessage, setLoadingMessage] = useState('');
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
// Cycle through loading messages for better UX
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
{loadingMessage}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
</Modal>
);
};
return (
<>
{isLoading && <FullscreenLoader />}
{!isLoading && (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose}>
<div className="flex flex-col">
<h3 className="text-sm">Select the type of your existing collection :</h3>
<div className="mt-4 grid grid-rows-2 grid-flow-col gap-2">
<CollectionButton onClick={handleImportBrunoCollection}>Bruno Collection</CollectionButton>
<CollectionButton onClick={handleImportPostmanCollection}>Postman Collection</CollectionButton>
<CollectionButton onClick={handleImportInsomniaCollection}>Insomnia Collection</CollectionButton>
<CollectionButton onClick={handleImportOpenapiCollection}>OpenAPI V3 Spec</CollectionButton>
</div>
</div>
</Modal>
)}
</>
);
};

View File

@@ -61,7 +61,6 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
id="collection-location"
type="text"
name="collectionLocation"
readOnly={true}
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
@@ -69,6 +68,9 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, collectionName }) =>
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={e => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
</>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (

View File

@@ -14,7 +14,7 @@ import Dropdown from "components/Dropdown";
import { IconCaretDown } from "@tabler/icons";
import StyledWrapper from './StyledWrapper';
const NewFolder = ({ collection, item, onClose }) => {
const NewFolder = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const [isEditing, toggleEditing] = useState(false);
@@ -52,7 +52,7 @@ const NewFolder = ({ collection, item, onClose }) => {
})
}),
onSubmit: (values) => {
dispatch(newFolder(values.folderName, values.directoryName, collection.uid, item ? item.uid : null))
dispatch(newFolder(values.folderName, values.directoryName, collectionUid, item ? item.uid : null))
.then(() => {
toast.success('New folder created!');
onClose();

View File

@@ -5,7 +5,7 @@ import toast from 'react-hot-toast';
import path from 'utils/common/path';
import { uuid } from 'utils/common';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { newEphemeralHttpRequest } from 'providers/ReduxStore/slices/collections';
import { newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { addTab } from 'providers/ReduxStore/slices/tabs';
@@ -20,9 +20,11 @@ import Portal from 'components/Portal';
import Help from 'components/Help';
import StyledWrapper from './StyledWrapper';
const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
const NewRequest = ({ collectionUid, item, isEphemeral, onClose }) => {
const dispatch = useDispatch();
const inputRef = useRef();
const collection = useSelector(state => state.collections.collections?.find(c => c.uid === collectionUid));
const {
brunoConfig: { presets: collectionPresets = {} }
} = collection;
@@ -135,14 +137,14 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
collectionUid: collection.uid
collectionUid: collectionUid
})
)
.then(() => {
dispatch(
addTab({
uid: uid,
collectionUid: collection.uid,
collectionUid: collectionUid,
requestPaneTab: getDefaultRequestPaneTab({ type: values.requestType })
})
);
@@ -158,7 +160,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: curlRequestTypeDetected,
requestUrl: request.url,
requestMethod: request.method,
collectionUid: collection.uid,
collectionUid: collectionUid,
itemUid: item ? item.uid : null,
headers: request.headers,
body: request.body,
@@ -178,7 +180,7 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
requestType: values.requestType,
requestUrl: values.requestUrl,
requestMethod: values.requestMethod,
collectionUid: collection.uid,
collectionUid: collectionUid,
itemUid: item ? item.uid : null
})
)
@@ -389,8 +391,6 @@ const NewRequest = ({ collection, item, isEphemeral, onClose }) => {
) : (
<div className='relative flex flex-row gap-1 items-center justify-between'>
<PathDisplay
collection={collection}
dirName={path.relative(collection?.pathname, item?.pathname || collection?.pathname)}
baseName={formik.values.filename? `${formik.values.filename}.bru` : ''}
/>
</div>

View File

@@ -11,6 +11,8 @@ import { useDispatch } from 'react-redux';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { openCollection, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { multiLineMsg } from "utils/common";
import { formatIpcError } from "utils/common/error";
const TitleBar = () => {
const [importedCollection, setImportedCollection] = useState(null);
@@ -34,9 +36,8 @@ const TitleBar = () => {
toast.success('Collection imported successfully');
})
.catch((err) => {
setImportCollectionLocationModalOpen(false);
console.error(err);
toast.error('An error occurred while importing the collection. Check the logs for more information.');
toast.error(multiLineMsg('An error occurred while importing the collection.', formatIpcError(err)));
});
};

View File

@@ -146,7 +146,7 @@ class SingleLineEditor extends Component {
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams);
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', this.props.highlightPathParams, true);
this.editor.setOption('mode', 'brunovariables');
};

View File

@@ -1,27 +1,24 @@
import React, { useState, useEffect } from 'react';
const StopWatch = () => {
const [milliseconds, setMilliseconds] = useState(0);
const tickInterval = 100;
const tick = () => {
setMilliseconds(_milliseconds => _milliseconds + tickInterval);
};
const StopWatch = ({ startTime }) => {
const [currentTime, setCurrentTime] = useState(Date.now());
useEffect(() => {
let timerID = setInterval(() => {
tick()
}, tickInterval);
return () => {
clearTimeout(timerID);
};
}, []);
if (milliseconds < 250) {
return 'Loading...';
}
let seconds = milliseconds / 1000;
if (!startTime) return;
const intervalId = setInterval(() => {
setCurrentTime(Date.now());
}, 100);
return () => clearInterval(intervalId);
}, [startTime]);
if (!startTime) return <span>Loading...</span>;
const elapsedTime = currentTime - startTime;
if (elapsedTime < 250) return <span>Loading...</span>;
const seconds = elapsedTime / 1000;
return <span>{seconds.toFixed(1)}s</span>;
};

View File

@@ -211,13 +211,15 @@ export const HotkeysProvider = (props) => {
};
}, [activeTabUid, tabs, collections, dispatch]);
const currentCollection = getCurrentCollection();
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showEnvSettingsModal && (
<EnvironmentSettings collection={getCurrentCollection()} onClose={() => setShowEnvSettingsModal(false)} />
<EnvironmentSettings collection={currentCollection} onClose={() => setShowEnvSettingsModal(false)} />
)}
{showNewRequestModal && (
<NewRequest collection={getCurrentCollection()} onClose={() => setShowNewRequestModal(false)} />
<NewRequest collectionUid={currentCollection?.uid} onClose={() => setShowNewRequestModal(false)} />
)}
<div>{props.children}</div>
</HotkeysContext.Provider>

View File

@@ -13,12 +13,9 @@ import {
findEnvironmentInCollection,
findItemInCollection,
findParentItemInCollection,
getItemsToResequence,
isItemAFolder,
refreshUidsInItem,
isItemARequest,
moveCollectionItem,
moveCollectionItemToRootOfCollection,
transformRequestToSaveToFilesystem
} from 'utils/collections';
import { uuid, waitForNextTick } from 'utils/common';
@@ -38,6 +35,7 @@ import {
responseReceived,
updateLastAction,
setCollectionSecurityConfig,
setRequestStartTime,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl
} from './index';
@@ -47,8 +45,7 @@ import { closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { resolveRequestFilename } from 'utils/common/platform';
import { parsePathParams, parseQueryParams, splitOnFirst } from 'utils/url/index';
import { sendCollectionOauth2Request as _sendCollectionOauth2Request } from 'utils/network/index';
import { getGlobalEnvironmentVariables } from 'utils/collections/index';
import { findCollectionByPathname, findEnvironmentInCollectionByName } from 'utils/collections/index';
import { getGlobalEnvironmentVariables, findCollectionByPathname, findEnvironmentInCollectionByName, getReorderedItemsInTargetDirectory, resetSequencesInFolder, getReorderedItemsInSourceDirectory, calculateDraggedItemNewPathname } from 'utils/collections/index';
import { sanitizeName } from 'utils/common/regex';
import { safeParseJSON, safeStringifyJSON } from 'utils/common/index';
@@ -60,7 +57,7 @@ export const renameCollection = (newName, collectionUid) => (dispatch, getState)
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:rename-collection', newName, collection.pathname).then(resolve).catch(reject);
});
};
@@ -225,6 +222,12 @@ export const sendRequest = (item, collectionUid) => (dispatch, getState) => {
const { globalEnvironments, activeGlobalEnvironmentUid } = state.globalEnvironments;
const collection = findCollectionByUid(state.collections.collections, collectionUid);
dispatch(setRequestStartTime({
itemUid: item.uid,
collectionUid: collectionUid,
timestamp: Date.now()
}));
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
@@ -337,6 +340,7 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
})
);
const { ipcRenderer } = window;
ipcRenderer
.invoke(
'renderer:run-collection-folder',
@@ -358,6 +362,8 @@ export const runCollectionFolder = (collectionUid, folderUid, recursive, delay)
export const newFolder = (folderName, directoryName, collectionUid, itemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const parentItem = itemUid ? findItemInCollection(collection, itemUid) : collection;
const items = filter(parentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
return new Promise((resolve, reject) => {
if (!collection) {
@@ -372,10 +378,32 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
if (!folderWithSameNameExists) {
const fullName = path.join(collection.pathname, directoryName);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.invoke('renderer:new-folder', fullName)
.then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
},
request: {
auth: {
mode: 'inherit'
}
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -392,8 +420,31 @@ export const newFolder = (folderName, directoryName, collectionUid, itemUid) =>
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:new-folder', fullName, folderName)
.then(() => resolve())
.invoke('renderer:new-folder', fullName)
.then(async () => {
const folderData = {
name: folderName,
pathname: fullName,
root: {
meta: {
name: folderName,
seq: items?.length + 1
},
request: {
auth: {
mode: 'inherit'
}
}
}
};
ipcRenderer
.invoke('renderer:save-folder-root', folderData)
.then(resolve)
.catch((err) => {
toast.error('Failed to save folder settings!');
reject(err);
});
})
.catch((error) => reject(error));
} else {
return reject(new Error('Duplicate folder names under same parent folder are not allowed'));
@@ -495,8 +546,11 @@ export const cloneItem = (newName, newFilename, itemUid, collectionUid) => (disp
set(item, 'name', newName);
set(item, 'filename', newFilename);
set(item, 'root.meta.name', newName);
set(item, 'root.meta.seq', parentFolder?.items?.length + 1);
const collectionPath = path.join(parentFolder.pathname, newFilename);
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:clone-folder', item, collectionPath).then(resolve).catch(reject);
return;
}
@@ -594,176 +648,114 @@ export const deleteItem = (itemUid, collectionUid) => (dispatch, getState) => {
export const sortCollections = (payload) => (dispatch) => {
dispatch(_sortCollections(payload));
};
export const moveItem = (collectionUid, draggedItemUid, targetItemUid) => (dispatch, getState) => {
export const moveItem = ({ targetDirname, sourcePathname }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:move-item', { targetDirname, sourcePathname })
.then(resolve)
.catch(reject);
});
}
export const handleCollectionItemDrop = ({ targetItem, draggedItem, dropType, collectionUid }) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
const { uid: draggedItemUid, pathname: draggedItemPathname } = draggedItem;
const { uid: targetItemUid, pathname: targetItemPathname } = targetItem;
const targetItemDirectory = findParentItemInCollection(collection, targetItemUid) || collection;
const targetItemDirectoryItems = cloneDeep(targetItemDirectory.items);
const draggedItemDirectory = findParentItemInCollection(collection, draggedItemUid) || collection;
const draggedItemDirectoryItems = cloneDeep(draggedItemDirectory.items);
const handleMoveToNewLocation = async ({ draggedItem, draggedItemDirectoryItems, targetItem, targetItemDirectoryItems, newPathname, dropType }) => {
const { uid: targetItemUid } = targetItem;
const { pathname: draggedItemPathname, uid: draggedItemUid } = draggedItem;
const newDirname = path.dirname(newPathname);
await dispatch(moveItem({
targetDirname: newDirname,
sourcePathname: draggedItemPathname
}));
// Update sequences in the source directory
if (draggedItemDirectoryItems?.length) {
// reorder items in the source directory
const draggedItemDirectoryItemsWithoutDraggedItem = draggedItemDirectoryItems.filter(i => i.uid !== draggedItemUid);
const reorderedSourceItems = getReorderedItemsInSourceDirectory({ items: draggedItemDirectoryItemsWithoutDraggedItem });
if (reorderedSourceItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedSourceItems }));
}
}
// Update sequences in the target directory (if dropping adjacent)
if (dropType === 'adjacent') {
const targetItemSequence = targetItemDirectoryItems.findIndex(i => i.uid === targetItemUid)?.seq;
const draggedItemWithNewPathAndSequence = {
...draggedItem,
pathname: newPathname,
seq: targetItemSequence
};
// draggedItem is added to the targetItem's directory
const reorderedTargetItems = getReorderedItemsInTargetDirectory({
items: [ ...targetItemDirectoryItems, draggedItemWithNewPathAndSequence ],
targetItemUid,
draggedItemUid
});
if (reorderedTargetItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedTargetItems }));
}
}
};
const handleReorderInSameLocation = async ({ draggedItem, targetItem, targetItemDirectoryItems }) => {
const { uid: targetItemUid } = targetItem;
const { uid: draggedItemUid } = draggedItem;
// reorder items in the targetItem's directory
const reorderedItems = getReorderedItemsInTargetDirectory({
items: targetItemDirectoryItems,
targetItemUid,
draggedItemUid
});
if (reorderedItems?.length) {
await dispatch(updateItemsSequences({ itemsToResequence: reorderedItems }));
}
};
return new Promise(async (resolve, reject) => {
try {
const newPathname = calculateDraggedItemNewPathname({ draggedItem, targetItem, dropType, collectionPathname: collection.pathname });
if (!newPathname) return;
if (targetItemPathname?.startsWith(draggedItemPathname)) return;
if (newPathname !== draggedItemPathname) {
await handleMoveToNewLocation({ targetItem, targetItemDirectoryItems, draggedItem, draggedItemDirectoryItems, newPathname, dropType });
} else {
await handleReorderInSameLocation({ draggedItem, targetItemDirectoryItems, targetItem });
}
resolve();
} catch (error) {
console.error(error);
toast.error(error?.message);
reject(error);
}
})
}
export const updateItemsSequences = ({ itemsToResequence }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
const targetItem = findItemInCollection(collectionCopy, targetItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
if (!targetItem) {
return reject(new Error('Target item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
const targetItemParent = findParentItemInCollection(collectionCopy, targetItemUid);
const sameParent = draggedItemParent === targetItemParent;
// file item dragged onto another file item and both are in the same folder
// this is also true when both items are at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && sameParent) {
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:resequence-items', itemsToResequence)
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item which is at the root level
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged onto another file item and both are in different folders
if (isItemARequest(draggedItem) && isItemARequest(targetItem) && !sameParent) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItemParent, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItemParent.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// file item dragged into its own folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// file item dragged into another folder
if (isItemARequest(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
moveCollectionItem(collectionCopy, draggedItem, targetItem);
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(targetItem, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, targetItem.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
// end of the file drags, now let's handle folder drags
// folder drags are simpler since we don't allow ordering of folders
// folder dragged into its own folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent === targetItem) {
return resolve();
}
// folder dragged into a file which is at the same level
// this is also true when both items are at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && sameParent) {
return resolve();
}
// folder dragged into a file which is a child of the folder
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && draggedItem === targetItemParent) {
return resolve();
}
// folder dragged into a file which is at the root level
if (isItemAFolder(draggedItem) && isItemARequest(targetItem) && !targetItemParent) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
}
// folder dragged into another folder
if (isItemAFolder(draggedItem) && isItemAFolder(targetItem) && draggedItemParent !== targetItem) {
const draggedItemPathname = draggedItem.pathname;
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, targetItem.pathname)
.then(resolve)
.catch((error) => reject(error));
}
ipcRenderer.invoke('renderer:resequence-items', itemsToResequence)
.then(resolve)
.catch(reject);
});
};
export const moveItemToRootOfCollection = (collectionUid, draggedItemUid) => (dispatch, getState) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const collectionCopy = cloneDeep(collection);
const draggedItem = findItemInCollection(collectionCopy, draggedItemUid);
if (!draggedItem) {
return reject(new Error('Dragged item not found'));
}
const draggedItemParent = findParentItemInCollection(collectionCopy, draggedItemUid);
// file item is already at the root level
if (!draggedItemParent) {
return resolve();
}
const draggedItemPathname = draggedItem.pathname;
moveCollectionItemToRootOfCollection(collectionCopy, draggedItem);
if (isItemAFolder(draggedItem)) {
return ipcRenderer
.invoke('renderer:move-folder-item', draggedItemPathname, collectionCopy.pathname)
.then(resolve)
.catch((error) => reject(error));
} else {
const itemsToResequence = getItemsToResequence(draggedItemParent, collectionCopy);
const itemsToResequence2 = getItemsToResequence(collectionCopy, collectionCopy);
return ipcRenderer
.invoke('renderer:move-file-item', draggedItemPathname, collectionCopy.pathname)
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence))
.then(() => ipcRenderer.invoke('renderer:resequence-items', itemsToResequence2))
.then(resolve)
.catch((error) => reject(error));
}
});
};
}
export const newHttpRequest = (params) => (dispatch, getState) => {
const { requestName, filename, requestType, requestUrl, requestMethod, collectionUid, itemUid, headers, body, auth } = params;
@@ -823,8 +815,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
collection.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(collection.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
const items = filter(collection.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = items.length + 1;
if (!reqWithSameNameExists) {
const fullName = path.join(collection.pathname, resolvedFilename);
@@ -852,8 +844,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
currentItem.items,
(i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename)
);
const requestItems = filter(currentItem.items, (i) => i.type !== 'folder');
item.seq = requestItems.length + 1;
const items = filter(currentItem.items, (i) => isItemAFolder(i) || isItemARequest(i));
item.seq = items.length + 1;
if (!reqWithSameNameExists) {
const fullName = path.join(currentItem.pathname, resolvedFilename);
const { ipcRenderer } = window;
@@ -885,6 +877,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, name)
.then(
@@ -913,6 +906,7 @@ export const importEnvironment = (name, variables, collectionUid) => (dispatch,
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
.then(
@@ -946,6 +940,7 @@ export const copyEnvironment = (name, baseEnvUid, collectionUid) => (dispatch, g
const sanitizedName = sanitizeName(name);
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, baseEnv.variables)
.then(
@@ -982,6 +977,7 @@ export const renameEnvironment = (newName, environmentUid, collectionUid) => (di
const oldName = environment.name;
environment.name = sanitizedName;
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:rename-environment', collection.pathname, oldName, sanitizedName))
@@ -1005,6 +1001,7 @@ export const deleteEnvironment = (environmentUid, collectionUid) => (dispatch, g
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-environment', collection.pathname, environment.name)
.then(resolve)
@@ -1028,6 +1025,7 @@ export const saveEnvironment = (variables, environmentUid, collectionUid) => (di
environment.variables = variables;
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-environment', collection.pathname, environment))
@@ -1053,7 +1051,8 @@ export const selectEnvironment = (environmentUid, collectionUid) => (dispatch, g
if (environmentUid && !environmentName) {
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:update-ui-state-snapshot', { type: 'COLLECTION_ENVIRONMENT', data: { collectionPath: collection?.pathname, environmentName }});
dispatch(_selectEnvironment({ environmentUid, collectionUid }));
@@ -1112,11 +1111,13 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (!collection) {
return reject(new Error('Collection not found'));
}
return new Promise((resolve, reject) => {
if (!collection) {
return reject(new Error('Collection not found'));
}
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:update-bruno-config', brunoConfig, collection.pathname, collectionUid)
.then(resolve)
@@ -1135,6 +1136,8 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
brunoConfig: brunoConfig
};
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer.invoke('renderer:get-collection-security-config', pathname).then((securityConfig) => {
collectionSchema

View File

@@ -1593,6 +1593,27 @@ export const collectionsSlice = createSlice({
case 'oauth2':
set(folder, 'root.request.auth.oauth2', action.payload.content);
break;
case 'basic':
set(folder, 'root.request.auth.basic', action.payload.content);
break;
case 'bearer':
set(folder, 'root.request.auth.bearer', action.payload.content);
break;
case 'digest':
set(folder, 'root.request.auth.digest', action.payload.content);
break;
case 'ntlm':
set(folder, 'root.request.auth.ntlm', action.payload.content);
break;
case 'apikey':
set(folder, 'root.request.auth.apikey', action.payload.content);
break;
case 'awsv4':
set(folder, 'root.request.auth.awsv4', action.payload.content);
break;
case 'wsse':
set(folder, 'root.request.auth.wsse', action.payload.content);
break;
}
}
},
@@ -1719,6 +1740,9 @@ export const collectionsSlice = createSlice({
folderItem.name = file?.data?.meta?.name;
}
folderItem.root = file.data;
if (file?.data?.meta?.seq) {
folderItem.seq = file.data?.meta?.seq;
}
}
return;
}
@@ -1795,9 +1819,10 @@ export const collectionsSlice = createSlice({
currentPath = path.join(currentPath, directoryName);
if (!childItem) {
childItem = {
uid: uuid(),
uid: dir?.meta?.uid || uuid(),
pathname: currentPath,
name: dir?.meta?.name || directoryName,
seq: dir?.meta?.seq || 1,
filename: directoryName,
collapsed: true,
type: 'folder',
@@ -1829,6 +1854,9 @@ export const collectionsSlice = createSlice({
if (file?.data?.meta?.name) {
folderItem.name = file?.data?.meta?.name;
}
if (file?.data?.meta?.seq) {
folderItem.seq = file?.data?.meta?.seq;
}
folderItem.root = file.data;
}
return;
@@ -2077,6 +2105,17 @@ export const collectionsSlice = createSlice({
}
}
},
setRequestStartTime: (state, action) => {
const { itemUid, collectionUid, timestamp } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
if (collection) {
const item = findItemInCollection(collection, itemUid);
if (item) {
item.requestStartTime = timestamp;
}
}
},
collectionAddOauth2CredentialsByUrl: (state, action) => {
const { collectionUid, folderUid, itemUid, url, credentials, credentialsId, debugInfo } = action.payload;
const collection = findCollectionByUid(state.collections, collectionUid);
@@ -2158,6 +2197,7 @@ export const collectionsSlice = createSlice({
);
return oauth2Credential;
},
updateFolderAuthMode: (state, action) => {
const collection = findCollectionByUid(state.collections, action.payload.collectionUid);
const folder = collection ? findItemInCollection(collection, action.payload.folderUid) : null;
@@ -2166,8 +2206,9 @@ export const collectionsSlice = createSlice({
set(folder, 'root.request.auth', {});
set(folder, 'root.request.auth.mode', action.payload.mode);
}
},
}
}
},
});
export const {
@@ -2273,12 +2314,13 @@ export const {
resetCollectionRunner,
updateRequestDocs,
updateFolderDocs,
moveCollection,
setRequestStartTime,
collectionAddOauth2CredentialsByUrl,
collectionClearOauth2CredentialsByUrl,
collectionGetOauth2CredentialsByUrl,
updateFolderAuth,
updateFolderAuthMode,
moveCollection
} = collectionsSlice.actions;
export default collectionsSlice.reducer;

View File

@@ -1,5 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import { stringifyIfNot, uuid } from 'utils/common/index';
import { uuid } from 'utils/common/index';
import { environmentSchema } from '@usebruno/schema';
import { cloneDeep } from 'lodash';
@@ -90,6 +90,7 @@ export const {
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const uid = uuid();
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { name, uid, variables })
.then(() => dispatch(_addGlobalEnvironment({ name, uid, variables })))
@@ -104,6 +105,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const baseEnv = globalEnvironments?.find(env => env?.uid == baseEnvUid)
const uid = uuid();
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-global-environment', { uid, name, variables: baseEnv.variables })
.then(() => dispatch(_copyGlobalEnvironment({ name, uid, variables: baseEnv.variables })))
@@ -114,6 +116,7 @@ export const copyGlobalEnvironment = ({ name, environmentUid: baseEnvUid }) => (
export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
const state = getState();
const globalEnvironments = state.globalEnvironments.globalEnvironments;
const environment = globalEnvironments?.find(env => env?.uid == environmentUid)
@@ -139,6 +142,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
return reject(new Error('Environment not found'));
}
const { ipcRenderer } = window;
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:save-global-environment', {
@@ -155,6 +159,7 @@ export const saveGlobalEnvironment = ({ variables, environmentUid }) => (dispatc
export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:select-global-environment', { environmentUid })
.then(() => dispatch(_selectGlobalEnvironment({ environmentUid })))
@@ -165,6 +170,7 @@ export const selectGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:delete-global-environment', { environmentUid })
.then(() => dispatch(_deleteGlobalEnvironment({ environmentUid })))
@@ -175,6 +181,7 @@ export const deleteGlobalEnvironment = ({ environmentUid }) => (dispatch, getSta
export const globalEnvironmentsUpdateEvent = ({ globalEnvironmentVariables }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
if (!globalEnvironmentVariables) resolve();
const state = getState();

View File

@@ -7,9 +7,17 @@ export const ToastContext = React.createContext();
export const ToastProvider = (props) => {
const { storedTheme } = useTheme();
const toastOptions = { duration: 2000 };
const toastOptions = {
duration: 2000,
style: {
// Break long word like file-path, URL etc. to prevent overflow
overflowWrap: 'anywhere'
}
};
if (storedTheme === 'dark') {
toastOptions.style = {
...toastOptions.style,
borderRadius: '10px',
background: '#3d3d3d',
color: '#fff'

View File

@@ -0,0 +1,9 @@
import { createSelector } from '@reduxjs/toolkit';
export const isTabForItemActive = ({ itemUid }) => createSelector([
(state) => state.tabs?.activeTabUid
], (activeTabUid) => activeTabUid === itemUid);
export const isTabForItemPresent = ({ itemUid }) => createSelector([
(state) => state.tabs.tabs,
], (tabs) => tabs.some((tab) => tab.uid === itemUid));

View File

@@ -281,6 +281,12 @@ const darkTheme = {
color: 'rgb(52 51 49)'
},
dragAndDrop: {
border: '#666666',
borderStyle: '2px solid',
hoverBg: 'rgba(102, 102, 102, 0.08)',
transition: 'all 0.1s ease'
},
infoTip: {
bg: '#1f1f1f',
border: '#333333',

View File

@@ -282,6 +282,12 @@ const lightTheme = {
color: 'rgb(152 151 149)'
},
dragAndDrop: {
border: '#8b8b8b', // Using the same gray as focusBorder from input
borderStyle: '2px solid',
hoverBg: 'rgba(139, 139, 139, 0.05)', // Matching the border color with reduced opacity
transition: 'all 0.1s ease'
},
infoTip: {
bg: 'white',
border: '#e0e0e0',

View File

@@ -5,6 +5,8 @@
* Copyright (C) 2017 by Marijn Haverbeke <marijnh@gmail.com> and others
*/
import { JSHINT } from 'jshint';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;

View File

@@ -98,6 +98,14 @@ export const findItemInCollectionByPathname = (collection, pathname) => {
return findItemByPathname(flattenedItems, pathname);
};
export const findParentItemInCollectionByPathname = (collection, pathname) => {
let flattenedItems = flattenItems(collection.items);
return find(flattenedItems, (item) => {
return item.items && find(item.items, (i) => i.pathname === pathname);
});
};
export const findItemInCollection = (collection, itemUid) => {
let flattenedItems = flattenItems(collection.items);
@@ -150,90 +158,6 @@ export const getItemsLoadStats = (folder) => {
};
}
export const moveCollectionItem = (collection, draggedItem, targetItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
if (draggedItemParent) {
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
draggedItem.pathname = path.join(draggedItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items = filter(collection.items, (i) => i.uid !== draggedItem.uid);
}
if (targetItem.type === 'folder') {
targetItem.items = sortBy(targetItem.items || [], (item) => item.seq);
targetItem.items.push(draggedItem);
draggedItem.pathname = path.join(targetItem.pathname, draggedItem.filename);
} else {
let targetItemParent = findParentItemInCollection(collection, targetItem.uid);
if (targetItemParent) {
targetItemParent.items = sortBy(targetItemParent.items, (item) => item.seq);
let targetItemIndex = findIndex(targetItemParent.items, (i) => i.uid === targetItem.uid);
targetItemParent.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(targetItemParent.pathname, draggedItem.filename);
} else {
collection.items = sortBy(collection.items, (item) => item.seq);
let targetItemIndex = findIndex(collection.items, (i) => i.uid === targetItem.uid);
collection.items.splice(targetItemIndex + 1, 0, draggedItem);
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
}
};
export const moveCollectionItemToRootOfCollection = (collection, draggedItem) => {
let draggedItemParent = findParentItemInCollection(collection, draggedItem.uid);
// If the dragged item is already at the root of the collection, do nothing
if (!draggedItemParent) {
return;
}
draggedItemParent.items = sortBy(draggedItemParent.items, (item) => item.seq);
draggedItemParent.items = filter(draggedItemParent.items, (i) => i.uid !== draggedItem.uid);
collection.items = sortBy(collection.items, (item) => item.seq);
collection.items.push(draggedItem);
if (draggedItem.type == 'folder') {
draggedItem.pathname = path.join(collection.pathname, draggedItem.name);
} else {
draggedItem.pathname = path.join(collection.pathname, draggedItem.filename);
}
};
export const getItemsToResequence = (parent, collection) => {
let itemsToResequence = [];
if (!parent) {
let index = 1;
each(collection.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
if (parent.items && parent.items.length) {
let index = 1;
each(parent.items, (item) => {
if (isItemARequest(item)) {
itemsToResequence.push({
pathname: item.pathname,
seq: index++
});
}
});
return itemsToResequence;
}
return itemsToResequence;
};
export const transformCollectionToSaveToExportAsFile = (collection, options = {}) => {
const copyHeaders = (headers) => {
return map(headers, (header) => {
@@ -502,6 +426,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
if (meta?.name) {
di.root.meta = {};
di.root.meta.name = meta?.name;
di.root.meta.seq = meta?.seq;
}
if (!Object.keys(di.root.request)?.length) {
delete di.root.request;
@@ -1086,3 +1011,77 @@ export const getFormattedCollectionOauth2Credentials = ({ oauth2Credentials = []
});
return credentialsVariables;
};
// item sequence utils - START
export const resetSequencesInFolder = (folderItems) => {
const items = folderItems;
const sortedItems = items.sort((a, b) => a.seq - b.seq);
return sortedItems.map((item, index) => {
item.seq = index + 1;
return item;
});
};
export const isItemBetweenSequences = (itemSequence, sourceItemSequence, targetItemSequence) => {
if (targetItemSequence > sourceItemSequence) {
return itemSequence > sourceItemSequence && itemSequence < targetItemSequence;
}
return itemSequence < sourceItemSequence && itemSequence >= targetItemSequence;
};
export const calculateNewSequence = (isDraggedItem, targetSequence, draggedSequence) => {
if (!isDraggedItem) {
return null;
}
return targetSequence > draggedSequence ? targetSequence - 1 : targetSequence;
};
export const getReorderedItemsInTargetDirectory = ({ items, targetItemUid, draggedItemUid }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
const targetItem = findItem(itemsWithFixedSequences, targetItemUid);
const draggedItem = findItem(itemsWithFixedSequences, draggedItemUid);
const targetSequence = targetItem?.seq;
const draggedSequence = draggedItem?.seq;
itemsWithFixedSequences?.forEach(item => {
const isDraggedItem = item?.uid === draggedItemUid;
const isBetween = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
if (isBetween) {
item.seq += targetSequence > draggedSequence ? -1 : 1;
}
const newSequence = calculateNewSequence(isDraggedItem, targetSequence, draggedSequence);
if (newSequence !== null) {
item.seq = newSequence;
}
});
// only return items that have been reordered
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
export const getReorderedItemsInSourceDirectory = ({ items }) => {
const itemsWithFixedSequences = resetSequencesInFolder(cloneDeep(items));
return itemsWithFixedSequences.filter(item =>
items?.find(originalItem => originalItem?.uid === item?.uid)?.seq !== item?.seq
);
};
export const calculateDraggedItemNewPathname = ({ draggedItem, targetItem, dropType, collectionPathname }) => {
const { pathname: targetItemPathname } = targetItem;
const { filename: draggedItemFilename } = draggedItem;
const targetItemDirname = path.dirname(targetItemPathname);
const isTargetTheCollection = targetItemPathname === collectionPathname;
const isTargetItemAFolder = isItemAFolder(targetItem);
if (dropType === 'inside' && (isTargetItemAFolder || isTargetTheCollection)) {
return path.join(targetItemPathname, draggedItemFilename)
} else if (dropType === 'adjacent') {
return path.join(targetItemDirname, draggedItemFilename)
}
return null;
};
// item sequence utils - END

View File

@@ -1,10 +0,0 @@
class Cache {
get(key) {
return window.localStorage.getItem(key);
}
set(key, val) {
window.localStorage.setItem(key, val);
}
}
module.exports = new Cache();

View File

@@ -74,11 +74,11 @@ export class MaskedEditor {
} else {
for (let line = 0; line < lineCount; line++) {
const lineLength = this.editor.getLine(line).length;
const maskedNode = document.createTextNode('*'.repeat(lineLength));
const maskedNode = document.createTextNode('*'.repeat(lineLength));
this.editor.markText(
{ line, ch: 0 },
{ line, ch: lineLength },
{ replacedWith: maskedNode, handleMouseEvents: false }
{ replacedWith: maskedNode, handleMouseEvents: false }
);
}
}
@@ -86,7 +86,18 @@ export class MaskedEditor {
};
}
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams) => {
/**
* Defines a custom CodeMirror mode for Bruno variables highlighting.
* This function creates a specialized mode that can highlight both Bruno template
* variables (in the format {{variable}}) and URL path parameters (in the format /:param).
*
* @param {Object} _variables - The variables object containing data to validate against
* @param {string} mode - The base CodeMirror mode to extend (e.g., 'javascript', 'application/json')
* @param {boolean} highlightPathParams - Whether to highlight URL path parameters
* @param {boolean} highlightVariables - Whether to highlight template variables
* @returns {void} - Registers the mode with CodeMirror for later use
*/
export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPathParams, highlightVariables) => {
CodeMirror.defineMode('brunovariables', function (config, parserConfig) {
const { pathParams = {}, ...variables } = _variables || {};
const variablesOverlay = {
@@ -139,13 +150,15 @@ export const defineCodeMirrorBrunoVariablesMode = (_variables, mode, highlightPa
}
};
let baseMode = CodeMirror.overlayMode(CodeMirror.getMode(config, parserConfig.backdrop || mode), variablesOverlay);
let baseMode = CodeMirror.getMode(config, parserConfig.backdrop || mode);
if (highlightPathParams) {
return CodeMirror.overlayMode(baseMode, urlPathParamsOverlay);
} else {
return baseMode;
if (highlightVariables) {
baseMode = CodeMirror.overlayMode(baseMode, variablesOverlay);
}
if (highlightPathParams) {
baseMode = CodeMirror.overlayMode(baseMode, urlPathParamsOverlay);
}
return baseMode;
});
};

View File

@@ -34,3 +34,11 @@ export const toastError = (error, defaultErrorMsg = 'An error occurred') => {
return toast.error(errorMsg);
};
export function formatIpcError(error) {
if (!(error instanceof Error)) return error;
if (!error?.message) return ''; // Avoid returning `null` or `undefined`
// https://github.com/electron/electron/blob/659e79fc08c6ffc2f7506dd1358918d97d240147/lib/renderer/api/ipc-renderer.ts#L24-L30
// There is no other way to get rid of this error prefix as of now.
return error.message.replace(/^Error invoking remote method '.+?': (Error: )?/, '');
}

View File

@@ -53,7 +53,7 @@ export const safeStringifyJSON = (obj, indent = false) => {
export const convertToCodeMirrorJson = (obj) => {
try {
return JSON5.stringify(obj).slice(1, -1);
return JSON.stringify(obj, null, 2).slice(1, -1);
} catch (e) {
return obj;
}
@@ -83,29 +83,40 @@ export const normalizeFileName = (name) => {
};
export const getContentType = (headers) => {
const headersArray = typeof headers === 'object' ? Object.entries(headers) : [];
if (headersArray.length > 0) {
let contentType = headersArray
.filter((header) => header[0].toLowerCase() === 'content-type')
.map((header) => {
return header[1];
});
if (contentType && contentType.length) {
if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?json/.test(contentType[0])) {
return 'application/ld+json';
} else if (typeof contentType[0] === 'string' && /^image\/svg\+xml/i.test(contentType[0])) {
return 'image/svg+xml';
} else if (typeof contentType[0] == 'string' && /^[\w\-]+\/([\w\-]+\+)?xml/.test(contentType[0])) {
return 'application/xml';
}
return contentType[0];
}
// Return empty string for invalid headers
if (!headers || typeof headers !== 'object' || Object.keys(headers).length === 0) {
return '';
}
return '';
};
// Get content-type header value
const contentTypeHeader = Object.entries(headers)
.find(([key]) => key.toLowerCase() === 'content-type');
const contentType = contentTypeHeader && contentTypeHeader[1];
// Return empty string if no content-type or not a string
if (!contentType || typeof contentType !== 'string') {
return '';
}
// This pattern matches content types like application/json, application/ld+json, text/json, etc.
const JSON_PATTERN = /^[\w\-]+\/([\w\-]+\+)?json/;
// This pattern matches content types like image/svg.
const SVG_PATTERN = /^image\/svg/i;
// This pattern matches content types like application/xml, text/xml, application/atom+xml, etc.
const XML_PATTERN = /^[\w\-]+\/([\w\-]+\+)?xml/;
if (JSON_PATTERN.test(contentType)) {
return 'application/ld+json';
} else if (SVG_PATTERN.test(contentType)) {
return 'image/svg+xml';
} else if (XML_PATTERN.test(contentType)) {
return 'application/xml';
}
return contentType;
}
export const startsWith = (str, search) => {
if (!str || !str.length || typeof str !== 'string') {
@@ -181,4 +192,8 @@ export const getEncoding = (headers) => {
// Parse the charset from content type: https://stackoverflow.com/a/33192813
const charsetMatch = /charset=([^()<>@,;:"/[\]?.=\s]*)/i.exec(headers?.['content-type'] || '');
return charsetMatch?.[1];
}
export const multiLineMsg = (...messages) => {
return messages.filter(m => m !== undefined && m !== null && m !== '').join('\n');
}

View File

@@ -1,6 +1,6 @@
const { describe, it, expect } = require('@jest/globals');
import { normalizeFileName, startsWith, humanizeDate, relativeDate } from './index';
import { normalizeFileName, startsWith, humanizeDate, relativeDate, getContentType } from './index';
describe('common utils', () => {
describe('normalizeFileName', () => {
@@ -107,4 +107,45 @@ describe('common utils', () => {
expect(relativeDate(date)).toBe('2 months ago');
});
});
describe('getContentType', () => {
it('should handle JSON content types correctly', () => {
expect(getContentType({ 'content-type': 'application/json' })).toBe('application/ld+json');
expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
expect(getContentType({ 'content-type': 'application/ld+json' })).toBe('application/ld+json');
});
it('should handle XML content types correctly', () => {
expect(getContentType({ 'content-type': 'text/xml' })).toBe('application/xml');
expect(getContentType({ 'content-type': 'application/xml' })).toBe('application/xml');
expect(getContentType({ 'content-type': 'application/atom+xml' })).toBe('application/xml');
});
it('should handle image content types correctly', () => {
expect(getContentType({ 'content-type': 'image/svg+xml;charset=utf-8' })).toBe('image/svg+xml');
expect(getContentType({ 'content-type': 'IMAGE/SVG+xml' })).toBe('image/svg+xml');
});
it('should return original content type when no pattern matches', () => {
expect(getContentType({ 'content-type': 'image/jpeg' })).toBe('image/jpeg');
expect(getContentType({ 'content-type': 'application/pdf' })).toBe('application/pdf');
});
it('should not be case sensitive', () => {
expect(getContentType({ 'content-type': 'text/json' })).toBe('application/ld+json');
expect(getContentType({ 'Content-Type': 'text/json' })).toBe('application/ld+json');
});
it('should handle empty content type', () => {
expect(getContentType({ 'content-type': '' })).toBe('');
expect(getContentType({ 'content-type': null })).toBe('');
expect(getContentType({ 'content-type': undefined })).toBe('');
});
it('should handle empty or invalid inputs', () => {
expect(getContentType({})).toBe('');
expect(getContentType(null)).toBe('');
expect(getContentType(undefined)).toBe('');
});
});
});

View File

@@ -1,16 +1,16 @@
const invalidCharacters = /[<>:"/\\|?*\x00-\x1F]/g; // replace invalid characters with hyphens
const reservedDeviceNames = /^(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])$/i;
const firstCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot, space, or hyphen at start
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no invalid characters
const lastCharacter = /^[^.\s\-\<>:"/\\|?*\x00-\x1F]/; // no dot or space at end, hyphen allowed
const firstCharacter = /^[^\s\-<>:"/\\|?*\x00-\x1F]/; // no space, hyphen and `invalidCharacters`
const middleCharacters = /^[^<>:"/\\|?*\x00-\x1F]*$/; // no `invalidCharacters`
const lastCharacter = /[^.\s<>:"/\\|?*\x00-\x1F]$/; // no dot, space and `invalidCharacters`
export const variableNameRegex = /^[\w-.]*$/;
export const sanitizeName = (name) => {
name = name
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[.\s-]+/, '') // remove leading dots, hyphens and spaces
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces (keep trailing hyphens)
.replace(invalidCharacters, '-') // replace invalid characters with hyphens
.replace(/^[\s\-]+/, '') // remove leading spaces and hyphens
.replace(/[.\s]+$/, ''); // remove trailing dots and spaces
return name;
};

View File

@@ -23,8 +23,8 @@ describe('regex validators', () => {
});
it('should remove trailing periods', () => {
expect(sanitizeName('.file')).toBe('file');
expect(sanitizeName('.file.')).toBe('file');
expect(sanitizeName('.file')).toBe('.file');
expect(sanitizeName('.file.')).toBe('.file');
expect(sanitizeName('file.')).toBe('file');
expect(sanitizeName('file.name.')).toBe('file.name');
expect(sanitizeName('hello world.')).toBe('hello world');
@@ -83,11 +83,11 @@ describe('regex validators', () => {
it('should handle filenames with multiple consecutive periods (only remove trailing)', () => {
expect(sanitizeName('file.name...')).toBe('file.name');
expect(sanitizeName('...file')).toBe('file');
expect(sanitizeName('...file')).toBe('...file');
expect(sanitizeName('file.name... ')).toBe('file.name');
expect(sanitizeName(' ...file')).toBe('file');
expect(sanitizeName(' ...file ')).toBe('file');
expect(sanitizeName(' ...file.... ')).toBe('file');
expect(sanitizeName(' ...file')).toBe('...file');
expect(sanitizeName(' ...file ')).toBe('...file');
expect(sanitizeName(' ...file.... ')).toBe('...file');
});
it('should handle very long filenames', () => {

View File

@@ -183,7 +183,13 @@ const curlToJson = (curlCommand) => {
if (request.query) {
requestJson.queries = getQueries(request);
} else if (request.multipartUploads || request.isDataBinary) {
} else if (request.multipartUploads) {
requestJson.data = request.multipartUploads;
if (!requestJson.headers) {
requestJson.headers = {};
}
requestJson.headers['Content-Type'] = 'multipart/form-data';
} else if (request.isDataBinary) {
Object.assign(requestJson, getFilesString(request));
} else if (typeof request.data === 'string' || typeof request.data === 'number') {
Object.assign(requestJson, getDataString(request));

View File

@@ -37,7 +37,8 @@ const parseCurlCommand = (curlCommand) => {
alias: {
H: 'header',
A: 'user-agent',
u: 'user'
u: 'user',
F: 'form'
}
});
@@ -95,17 +96,31 @@ const parseCurlCommand = (curlCommand) => {
cookieString = parsedArguments.cookie;
}
let multipartUploads;
if (parsedArguments.F) {
multipartUploads = {};
if (!Array.isArray(parsedArguments.F)) {
parsedArguments.F = [parsedArguments.F];
}
parsedArguments.F.forEach((multipartArgument) => {
// input looks like key=value. value could be json or a file path prepended with an @
const splitArguments = multipartArgument.split('=', 2);
const key = splitArguments[0];
const value = splitArguments[1];
multipartUploads[key] = value;
// Handle multipart form data specified via -F or --form flags
// Example: curl -F 'id=123' -F 'file=@/path/to/file.txt'
if (parsedArguments.F || parsedArguments.form) {
multipartUploads = [];
const formArgs = parsedArguments.F || parsedArguments.form;
const formArray = Array.isArray(formArgs) ? formArgs : [formArgs];
formArray.forEach((multipartArgument) => {
// Parse each form field using regex:
// - Group 1: Field name before =
// - Group 2: Value in quotes after = (for text fields)
// - Group 3: Value after @ (for file fields)
const match = multipartArgument.match(/^([^=]+)=(?:@?"([^"]*)"|([^@]*))?$/);
if (match) {
const key = match[1];
const value = match[2] || match[3] || '';
const isFile = multipartArgument.includes('@');
multipartUploads.push({
name: key,
value: value,
type: isFile ? 'file' : 'text',
enabled: true
});
}
});
}
if (cookieString) {

View File

@@ -0,0 +1,145 @@
const { describe, it, expect } = require('@jest/globals');
import parseCurlCommand from './parse-curl';
describe('parseCurlCommand', () => {
describe('basic functionality', () => {
it('should handle basic GET request', () => {
const result = parseCurlCommand('curl https://api.example.com/users');
expect(result).toEqual({
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users',
method: 'get'
});
});
it('should parse explicit POST method', () => {
const result = parseCurlCommand('curl -X POST https://api.example.com/users');
expect(result).toEqual({
url: 'https://api.example.com/users',
urlWithoutQuery: 'https://api.example.com/users',
method: 'post'
});
});
});
describe('headers handling', () => {
it('should parse multiple headers', () => {
const result = parseCurlCommand(
`curl -H 'Content-Type: application/json' -H 'Authorization: Bearer token' https://api.example.com`
);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer token'
}
});
});
it('should parse user-agent', () => {
const result = parseCurlCommand(`curl -A 'Custom Agent' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
headers: {
'User-Agent': 'Custom Agent'
}
});
});
});
describe('auth handling', () => {
it('should parse basic auth', () => {
const result = parseCurlCommand(`curl -u user:pass https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'get',
auth: {
mode: 'basic',
basic: {
username: 'user',
password: 'pass'
}
}
});
});
});
describe('data handling', () => {
it('should parse POST data', () => {
const result = parseCurlCommand(`curl -d 'foo=bar&baz=qux' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'post',
data: 'foo=bar&baz=qux'
});
});
it('should handle data-binary', () => {
const result = parseCurlCommand(`curl --data-binary '@file.json' https://api.example.com`);
expect(result).toEqual({
url: 'https://api.example.com',
urlWithoutQuery: 'https://api.example.com',
method: 'post',
data: '@file.json',
isDataBinary: true
});
});
});
describe('form data handling', () => {
it('should parse complex form data with multiple fields and file upload', () => {
const curlCommand = `curl --location 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d' \
--form 'id="1"' \
--form 'documentid="ADMINN_ID"' \
--form 'appoinID="12376"' \
--form 'autoclose="false"' \
--form 'fileData=@"/path/to/file"'`;
const result = parseCurlCommand(curlCommand);
expect(result).toEqual({
url: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
urlWithoutQuery: 'https://echo.usebruno.com/5cf47630-8d45-4fd3-937b-c4b1dea70c6d',
method: 'post',
multipartUploads: [
{
name: 'id',
value: '1',
type: 'text',
enabled: true
},
{
name: 'documentid',
value: 'ADMINN_ID',
type: 'text',
enabled: true
},
{
name: 'appoinID',
value: '12376',
type: 'text',
enabled: true
},
{
name: 'autoclose',
value: 'false',
type: 'text',
enabled: true
},
{
name: 'fileData',
value: '/path/to/file',
type: 'file',
enabled: true
}
]
});
});
});
});

View File

@@ -1,6 +1,5 @@
import fileDialog from 'file-dialog';
import { BrunoError } from 'utils/common/error';
import { postmanToBruno } from '@usebruno/converters';
import { safeParseJSON } from 'utils/common/index';
const readFile = (files) => {
@@ -12,18 +11,15 @@ const readFile = (files) => {
});
};
const importCollection = () => {
const postmanToBruno = (collection) => {
return new Promise((resolve, reject) => {
fileDialog({ accept: 'application/json' })
.then(readFile)
.then((collection) => postmanToBruno(collection))
.then((collection) => resolve({ collection }))
.catch((err) => {
console.log(err);
reject(new BrunoError('Import collection failed'));
})
window.ipcRenderer.invoke('renderer:convert-postman-to-bruno', collection)
.then(result => resolve(result))
.catch(err => {
console.error('Error converting Postman to Bruno via Electron:', err);
reject(new BrunoError('Conversion failed'));
});
});
};
export default importCollection;
export { postmanToBruno, readFile };

View File

@@ -0,0 +1,126 @@
import { resetSequencesInFolder, isItemBetweenSequences } from 'utils/collections/index';
describe('resetSequencesInFolder', () => {
it('should fix the sequences in the folder 1', () => {
const folder = {
items: [
{ uid: '1', seq: 1 },
{ uid: '2', seq: 3 },
{ uid: '3', seq: 6 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '1', seq: 1 },
{ uid: '2', seq: 2 },
{ uid: '3', seq: 3 },
]);
});
it('should fix the sequences in the folder 2', () => {
const folder = {
items: [
{ uid: '1', seq: 3 },
{ uid: '2', seq: 1 },
{ uid: '3', seq: 2 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '2', seq: 1 },
{ uid: '3', seq: 2 },
{ uid: '1', seq: 3 },
]);
});
it('should fix the sequences in the folder with missing sequences', () => {
const folder = {
items: [
{ uid: '1', seq: 1 },
{ uid: '2', type: 'folder' },
{ uid: '3', type: 'folder' },
{ uid: '4', seq: 7 },
]
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '1', seq: 1 },
{ uid: '2', seq: 2, type: 'folder' },
{ uid: '3', seq: 3, type: 'folder' },
{ uid: '4', seq: 4 },
]);
});
it('should fix the sequences in the folder with same sequences', () => {
const folder = {
items: [
{ uid: '1', seq: 2 },
{ uid: '2', seq: 2 },
{ uid: '3', seq: 3 },
{ uid: '4', seq: 1 },
],
};
const fixedFolder = resetSequencesInFolder(folder.items);
expect(fixedFolder).toEqual([
{ uid: '4', seq: 1 },
{ uid: '1', seq: 2 },
{ uid: '2', seq: 3 },
{ uid: '3', seq: 4 },
]);
});
});
describe('isItemBetweenSequences', () => {
it('should return true if the item is between the sequences 1', () => {
const item = { uid: '1', seq: 2 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 2', () => {
const item = { uid: '1', seq: 2 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 3', () => {
const item = { uid: '1', seq: 4 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return true if the item is between the sequences 4', () => {
const item = { uid: '1', seq: 1 };
const draggedSequence = 5;
const targetSequence = 1;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(true);
});
it('should return false if the item is between the sequences 1', () => {
const item = { uid: '1', seq: 1 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(false);
});
it('should return false if the item is between the sequences 2', () => {
const item = { uid: '1', seq: 5 };
const draggedSequence = 1;
const targetSequence = 5;
const result = isItemBetweenSequences(item?.seq, draggedSequence, targetSequence);
expect(result).toBe(false);
});
});

View File

@@ -52,6 +52,7 @@
"@usebruno/lang": "0.12.0",
"@usebruno/vm2": "^3.9.13",
"@usebruno/requests": "^0.1.0",
"@usebruno/converters": "^0.1.0",
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
"axios-ntlm": "^1.4.2",
@@ -63,6 +64,7 @@
"http-proxy-agent": "^7.0.0",
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"qs": "^6.11.0",
"socks-proxy-agent": "^8.0.2",

View File

@@ -58,6 +58,44 @@ If you need to limit the trusted CA to a specified set when validating the reque
bru run request.bru --cacert myCustomCA.pem --ignore-truststore
```
## Importing Collections
You can import collections from other formats, such as OpenAPI, using the import command:
```bash
bru import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"
```
You can also use the shorter form with aliases:
```bash
bru import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"
```
This creates a Bruno collection directory that can be opened in Bruno.
You can also import directly from a URL:
```bash
bru import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"
```
You can also export the collection as a JSON file:
```bash
bru import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"
```
Import Options:
| Option | Details |
| ------------------------- | -------------------------------------------------- |
| --source, -s | Path to the source file or URL (required) |
| --output, -o | Path to the output directory |
| --output-file, -f | Path to the output JSON file |
| --collection-name, -n | Name for the imported collection |
| --insecure | Skip SSL certificate validation when fetching from URLs |
## Command Line Options
| Option | Details |

View File

@@ -0,0 +1,230 @@
const fs = require('fs');
const path = require('path');
const chalk = require('chalk');
const jsyaml = require('js-yaml');
const axios = require('axios');
const { openApiToBruno } = require('@usebruno/converters');
const { exists, isDirectory, sanitizeName } = require('../utils/filesystem');
const { createCollectionFromBrunoObject } = require('../utils/collection');
const command = 'import <type>';
const desc = 'Import a collection from other formats';
const builder = (yargs) => {
yargs
.positional('type', {
describe: 'Type of collection to import',
type: 'string',
choices: ['openapi']
})
.option('source', {
alias: 's',
describe: 'Path to the source file or URL',
type: 'string',
demandOption: true
})
.option('output', {
alias: 'o',
describe: 'Path to the output directory',
type: 'string',
conflicts: 'output-file'
})
.option('output-file', {
alias: 'f',
describe: 'Path to the output JSON file',
type: 'string',
conflicts: 'output'
})
.option('collection-name', {
alias: 'n',
describe: 'Name for the imported collection',
type: 'string'
})
.option('insecure', {
type: 'boolean',
describe: 'Skip SSL certificate verification when fetching from URLs',
default: false
})
.example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"')
.example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"')
.example('$0 import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"')
.example('$0 import openapi --source https://self-signed.example.com/api.json --insecure --output ~/Desktop')
.example('$0 import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"')
.example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"');
};
const isUrl = (str) => {
try {
return Boolean(new URL(str));
} catch (error) {
return false;
}
};
const readOpenApiFile = async (source, options = {}) => {
try {
let content;
if (isUrl(source)) {
// Handle URL input
console.log(chalk.yellow(`Fetching specification from URL: ${source}`));
try {
const axiosOptions = {
timeout: 30000, // 30 second timeout
maxContentLength: 10 * 1024 * 1024,
validateStatus: status => status >= 200 && status < 300
};
// Skip SSL certificate validation if insecure flag is set
if (options.insecure) {
console.log(chalk.yellow('Warning: SSL certificate verification is disabled. Use with caution.'));
axiosOptions.httpsAgent = new (require('https')).Agent({ rejectUnauthorized: false });
}
const response = await axios.get(source, axiosOptions);
content = response.data;
} catch (error) {
if (error.code === 'ECONNABORTED') {
throw new Error('Request timed out. The server took too long to respond.');
} else if (error.code === 'CERT_HAS_EXPIRED' || error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
error.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
throw new Error(`SSL Certificate error: ${error.code}. Try using --insecure if you trust this source.`);
} else if (error.response) {
throw new Error(`Failed to fetch from URL: ${error.response.status} ${error.response.statusText}`);
} else if (error.request) {
throw new Error(`No response received from server. Check the URL and your network connection.`);
} else {
throw new Error(`Error fetching URL: ${error.message}`);
}
}
// If response is already an object, return it directly
if (typeof content === 'object' && content !== null) {
return content;
}
} else {
// Handle file input
if (!await exists(source)) {
throw new Error(`File does not exist: ${source}`);
}
content = fs.readFileSync(source, 'utf8');
}
// If content is a string, try to parse as JSON or YAML
if (typeof content === 'string') {
try {
return JSON.parse(content);
} catch (jsonError) {
try {
return jsyaml.load(content);
} catch (yamlError) {
throw new Error('Failed to parse content as JSON or YAML');
}
}
}
return content;
} catch (error) {
// Let the specific error handling from above propagate
throw error;
}
};
const handler = async (argv) => {
try {
const { type, source, output, outputFile, collectionName, insecure } = argv;
if (!type || type !== 'openapi') {
console.error(chalk.red('Only OpenAPI import is supported currently'));
process.exit(1);
}
if (!source) {
console.error(chalk.red('Source file or URL is required'));
process.exit(1);
}
if (!output && !outputFile) {
console.error(chalk.red('Either --output or --output-file is required'));
process.exit(1);
}
console.log(chalk.yellow(`Reading OpenAPI specification from ${source}...`));
const openApiSpec = await readOpenApiFile(source, { insecure });
if (!openApiSpec) {
console.error(chalk.red('Failed to parse OpenAPI specification'));
process.exit(1);
}
console.log(chalk.yellow('Converting OpenAPI specification to Bruno format...'));
// Convert OpenAPI to Bruno format
let brunoCollection = openApiToBruno(openApiSpec);
// Override collection name if provided
if (collectionName) {
brunoCollection.name = collectionName;
}
if (outputFile) {
// Save as JSON file
const outputPath = path.resolve(outputFile);
fs.writeFileSync(outputPath, JSON.stringify(brunoCollection, null, 2));
console.log(chalk.green(`Bruno collection saved as JSON to ${outputPath}`));
} else if (output) {
const resolvedOutput = path.resolve(output);
// Check if output is an existing directory
const isOutputDirectory = await exists(resolvedOutput) && isDirectory(resolvedOutput);
// Determine the final output directory
let outputDir;
if (isOutputDirectory) {
// If output is an existing directory, use collection name to create a subdirectory
const dirName = sanitizeName(brunoCollection.name);
outputDir = path.join(resolvedOutput, dirName);
// Check if this subfolder already exists
if (await exists(outputDir)) {
const dirContents = fs.readdirSync(outputDir);
if (dirContents.length > 0) {
console.error(chalk.red(`Output directory is not empty: ${outputDir}`));
process.exit(1);
}
} else {
// Create the subfolder
fs.mkdirSync(outputDir, { recursive: true });
}
} else {
// If output doesn't exist or is not a directory, use it directly
outputDir = resolvedOutput;
// Check if parent directory exists
const parentDir = path.dirname(outputDir);
if (!await exists(parentDir)) {
console.error(chalk.red(`Parent directory does not exist: ${parentDir}`));
process.exit(1);
}
fs.mkdirSync(outputDir, { recursive: true });
}
await createCollectionFromBrunoObject(brunoCollection, outputDir);
console.log(chalk.green(`Bruno collection created at ${outputDir}`));
}
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
process.exit(1);
}
};
module.exports = {
command,
desc,
builder,
handler,
isUrl,
readOpenApiFile
};

View File

@@ -12,7 +12,7 @@ const { rpad } = require('../utils/common');
const { bruToJson, getOptions, collectionBruToJson } = require('../utils/bru');
const { dotenvToJson } = require('@usebruno/lang');
const constants = require('../constants');
const { findItemInCollection } = require('../utils/collection');
const { findItemInCollection, getAllRequestsInFolder, createCollectionJsonFromPathname } = require('../utils/collection');
const command = 'run [filename]';
const desc = 'Run a request';
@@ -22,6 +22,7 @@ const printRunSummary = (results) => {
passedRequests,
failedRequests,
skippedRequests,
errorRequests,
totalAssertions,
passedAssertions,
failedAssertions,
@@ -36,6 +37,9 @@ const printRunSummary = (results) => {
if (failedRequests > 0) {
requestSummary += `, ${chalk.red(`${failedRequests} failed`)}`;
}
if (errorRequests > 0) {
requestSummary += `, ${chalk.red(`${errorRequests} error`)}`;
}
if (skippedRequests > 0) {
requestSummary += `, ${chalk.magenta(`${skippedRequests} skipped`)}`;
}
@@ -62,6 +66,7 @@ const printRunSummary = (results) => {
passedRequests,
failedRequests,
skippedRequests,
errorRequests,
totalAssertions,
passedAssertions,
failedAssertions,
@@ -71,163 +76,6 @@ const printRunSummary = (results) => {
}
};
const createCollectionFromPath = (collectionPath) => {
const environmentsPath = path.join(collectionPath, `environments`);
const getFilesInOrder = (collectionPath) => {
let collection = {
pathname: collectionPath
};
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
const currentDirItems = [];
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (
stats.isDirectory() &&
filePath !== environmentsPath &&
!filePath.startsWith('.git') &&
!filePath.startsWith('node_modules')
) {
let folderItem = { name: file, pathname: filePath, type: 'folder', items: traverse(filePath) }
const folderBruFilePath = path.join(filePath, 'folder.bru');
const folderBruFileExists = fs.existsSync(folderBruFilePath);
if(folderBruFileExists) {
const folderBruContent = fs.readFileSync(folderBruFilePath, 'utf8');
let folderBruJson = collectionBruToJson(folderBruContent);
folderItem.root = folderBruJson;
}
currentDirItems.push(folderItem);
}
}
for (const file of filesInCurrentDir) {
if (['collection.bru', 'folder.bru'].includes(file)) {
continue;
}
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
const bruContent = fs.readFileSync(filePath, 'utf8');
const bruJson = bruToJson(bruContent);
currentDirItems.push({
name: file,
pathname: filePath,
...bruJson
});
}
}
return currentDirItems;
};
collection.items = traverse(collectionPath);
return collection;
};
return getFilesInOrder(collectionPath);
};
const getBruFilesRecursively = (dir, testsOnly) => {
const environmentsPath = 'environments';
const collection = {};
const getFilesInOrder = (dir) => {
let bruJsons = [];
const traverse = (currentPath) => {
const filesInCurrentDir = fs.readdirSync(currentPath);
if (currentPath.includes('node_modules')) {
return;
}
for (const file of filesInCurrentDir) {
const filePath = path.join(currentPath, file);
const stats = fs.statSync(filePath);
// todo: we might need a ignore config inside bruno.json
if (
stats.isDirectory() &&
filePath !== environmentsPath &&
!filePath.startsWith('.git') &&
!filePath.startsWith('node_modules')
) {
traverse(filePath);
}
}
const currentDirBruJsons = [];
for (const file of filesInCurrentDir) {
if (['collection.bru', 'folder.bru'].includes(file)) {
continue;
}
const filePath = path.join(currentPath, file);
const stats = fs.lstatSync(filePath);
if (!stats.isDirectory() && path.extname(filePath) === '.bru') {
const bruContent = fs.readFileSync(filePath, 'utf8');
const bruJson = bruToJson(bruContent);
const requestHasTests = bruJson.request?.tests;
const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
if (testsOnly) {
if (requestHasTests || requestHasActiveAsserts) {
currentDirBruJsons.push({
bruFilepath: filePath,
bruJson
});
}
} else {
currentDirBruJsons.push({
bruFilepath: filePath,
bruJson
});
}
}
}
// order requests by sequence
currentDirBruJsons.sort((a, b) => {
const aSequence = a.bruJson.seq || 0;
const bSequence = b.bruJson.seq || 0;
return aSequence - bSequence;
});
bruJsons = bruJsons.concat(currentDirBruJsons);
};
traverse(dir);
return bruJsons;
};
return getFilesInOrder(dir);
};
const getCollectionRoot = (dir) => {
const collectionRootPath = path.join(dir, 'collection.bru');
const exists = fs.existsSync(collectionRootPath);
if (!exists) {
return {};
}
const content = fs.readFileSync(collectionRootPath, 'utf8');
return collectionBruToJson(content);
};
const getFolderRoot = (dir) => {
const folderRootPath = path.join(dir, 'folder.bru');
const exists = fs.existsSync(folderRootPath);
if (!exists) {
return {};
}
const content = fs.readFileSync(folderRootPath, 'utf8');
return collectionBruToJson(content);
};
const getJsSandboxRuntime = (sandbox) => {
return sandbox === 'safe' ? 'quickjs' : 'vm2';
};
@@ -316,11 +164,15 @@ const builder = async (yargs) => {
type: 'string',
description: 'Path to the Client certificate config file used for securing the connection in the request'
})
.option('--noproxy', {
type: 'boolean',
description: 'Disable all proxy settings (both collection-defined and system proxies)',
default: false
})
.option('delay', {
type:"number",
description: "Delay between each requests (in miliseconds)"
})
.example('$0 run request.bru', 'Run a request')
.example('$0 run request.bru --env local', 'Run a request with the environment set to local')
.example('$0 run folder', 'Run all requests in a folder')
@@ -350,7 +202,6 @@ const builder = async (yargs) => {
'$0 run request.bru --reporter-junit results.xml --reporter-html results.html',
'Run a request and write the results to results.html in html format and results.xml in junit format in the current directory'
)
.example('$0 run request.bru --tests-only', 'Run all requests that have a test')
.example(
'$0 run request.bru --cacert myCustomCA.pem',
@@ -361,7 +212,8 @@ const builder = async (yargs) => {
'Use a custom CA certificate exclusively when validating the peers of the requests in the specified folder.'
)
.example('$0 run --client-cert-config client-cert-config.json', 'Run a request with Client certificate configurations')
.example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.');
.example('$0 run folder --delay delayInMs', 'Run a folder with given miliseconds delay between each requests.')
.example('$0 run --noproxy', 'Run requests with system proxy disabled');
};
const handler = async function (argv) {
@@ -386,29 +238,13 @@ const handler = async function (argv) {
reporterSkipAllHeaders,
reporterSkipHeaders,
clientCertConfig,
noproxy,
delay
} = argv;
const collectionPath = process.cwd();
// todo
// right now, bru must be run from the root of the collection
// will add support in the future to run it from anywhere inside the collection
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
const brunoJsonExists = await exists(brunoJsonPath);
if (!brunoJsonExists) {
console.error(chalk.red(`You can run only at the root of a collection`));
process.exit(constants.EXIT_STATUS.ERROR_NOT_IN_COLLECTION);
}
const brunoConfigFile = fs.readFileSync(brunoJsonPath, 'utf8');
const brunoConfig = JSON.parse(brunoConfigFile);
const collectionRoot = getCollectionRoot(collectionPath);
let collection = createCollectionFromPath(collectionPath);
collection = {
brunoConfig,
root: collectionRoot,
...collection
}
let collection = createCollectionJsonFromPathname(collectionPath);
const { root: collectionRoot, brunoConfig } = collection;
if (clientCertConfig) {
try {
@@ -444,7 +280,6 @@ const handler = async function (argv) {
}
}
if (filename && filename.length) {
const pathExists = await exists(filename);
if (!pathExists) {
@@ -510,6 +345,9 @@ const handler = async function (argv) {
if (disableCookies) {
options['disableCookies'] = true;
}
if (noproxy) {
options['noproxy'] = true;
}
if (cacert && cacert.length) {
if (insecure) {
console.error(chalk.red(`Ignoring the cacert option since insecure connections are enabled`));
@@ -566,54 +404,39 @@ const handler = async function (argv) {
const _isFile = isFile(filename);
let results = [];
let bruJsons = [];
let requestItems = [];
if (_isFile) {
console.log(chalk.yellow('Running Request \n'));
const bruContent = fs.readFileSync(filename, 'utf8');
const bruJson = bruToJson(bruContent);
bruJsons.push({
bruFilepath: filename,
bruJson
});
const requestItem = bruToJson(bruContent);
requestItem.pathname = path.resolve(collectionPath, filename);
requestItems.push(requestItem);
}
const _isDirectory = isDirectory(filename);
if (_isDirectory) {
if (!recursive) {
console.log(chalk.yellow('Running Folder \n'));
const files = fs.readdirSync(filename);
const bruFiles = files.filter((file) => !['folder.bru'].includes(file) && file.endsWith('.bru'));
for (const bruFile of bruFiles) {
const bruFilepath = path.join(filename, bruFile);
const bruContent = fs.readFileSync(bruFilepath, 'utf8');
const bruJson = bruToJson(bruContent);
const requestHasTests = bruJson.request?.tests;
const requestHasActiveAsserts = bruJson.request?.assertions.some((x) => x.enabled) || false;
if (testsOnly) {
if (requestHasTests || requestHasActiveAsserts) {
bruJsons.push({
bruFilepath,
bruJson
});
}
} else {
bruJsons.push({
bruFilepath,
bruJson
});
}
}
bruJsons.sort((a, b) => {
const aSequence = a.bruJson.seq || 0;
const bSequence = b.bruJson.seq || 0;
return aSequence - bSequence;
});
} else {
console.log(chalk.yellow('Running Folder Recursively \n'));
}
const resolvedFilepath = path.resolve(filename);
if (resolvedFilepath === collectionPath) {
requestItems = getAllRequestsInFolder(collection?.items, recursive);
} else {
const folderItem = findItemInCollection(collection, resolvedFilepath);
if (folderItem) {
requestItems = getAllRequestsInFolder(folderItem.items, recursive);
}
}
bruJsons = getBruFilesRecursively(filename, testsOnly);
if (testsOnly) {
requestItems = requestItems.filter((iter) => {
const requestHasTests = iter.request?.tests;
const requestHasActiveAsserts = iter.request?.assertions.some((x) => x.enabled) || false;
return requestHasTests || requestHasActiveAsserts;
});
}
}
@@ -625,11 +448,10 @@ const handler = async function (argv) {
if (itemPathname && !itemPathname?.endsWith('.bru')) {
itemPathname = `${itemPathname}.bru`;
}
const bruJson = cloneDeep(findItemInCollection(collection, itemPathname));
if (bruJson) {
const requestItem = cloneDeep(findItemInCollection(collection, itemPathname));
if (requestItem) {
const res = await runSingleRequest(
itemPathname,
bruJson,
requestItem,
collectionPath,
runtimeVariables,
envVars,
@@ -648,14 +470,13 @@ const handler = async function (argv) {
let currentRequestIndex = 0;
let nJumps = 0; // count the number of jumps to avoid infinite loops
while (currentRequestIndex < bruJsons.length) {
const iter = cloneDeep(bruJsons[currentRequestIndex]);
const { bruFilepath, bruJson } = iter;
while (currentRequestIndex < requestItems.length) {
const requestItem = cloneDeep(requestItems[currentRequestIndex]);
const { pathname } = requestItem;
const start = process.hrtime();
const result = await runSingleRequest(
bruFilepath,
bruJson,
requestItem,
collectionPath,
runtimeVariables,
envVars,
@@ -667,7 +488,7 @@ const handler = async function (argv) {
runSingleRequestByPathname
);
const isLastRun = currentRequestIndex === bruJsons.length - 1;
const isLastRun = currentRequestIndex === requestItems.length - 1;
const isValidDelay = !Number.isNaN(delay) && delay > 0;
if(isValidDelay && !isLastRun){
console.log(chalk.yellow(`Waiting for ${delay}ms or ${(delay/1000).toFixed(3)}s before next request.`));
@@ -681,7 +502,7 @@ const handler = async function (argv) {
results.push({
...result,
runtime: process.hrtime(start)[0] + process.hrtime(start)[1] / 1e9,
suitename: bruFilepath.replace('.bru', '')
suitename: pathname.replace('.bru', '')
});
if (reporterSkipAllHeaders) {
@@ -739,7 +560,7 @@ const handler = async function (argv) {
if (nextRequestName === null) {
break;
}
const nextRequestIdx = bruJsons.findIndex((iter) => iter.bruJson.name === nextRequestName);
const nextRequestIdx = requestItems.findIndex((iter) => iter.name === nextRequestName);
if (nextRequestIdx >= 0) {
currentRequestIndex = nextRequestIdx;
} else {
@@ -796,7 +617,7 @@ const handler = async function (argv) {
}
}
if (summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) {
if ((summary.failedAssertions + summary.failedTests + summary.failedRequests > 0) || (summary?.errorRequests > 0)) {
process.exit(constants.EXIT_STATUS.ERROR_FAILED_COLLECTION);
}
} catch (err) {

View File

@@ -156,6 +156,37 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
delete request.basicAuth;
}
if (request?.oauth2?.grantType) {
switch (request.oauth2.grantType) {
case 'password':
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
request.oauth2.username = _interpolate(request.oauth2.username) || '';
request.oauth2.password = _interpolate(request.oauth2.password) || '';
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
break;
case 'client_credentials':
request.oauth2.accessTokenUrl = _interpolate(request.oauth2.accessTokenUrl) || '';
request.oauth2.refreshTokenUrl = _interpolate(request.oauth2.refreshTokenUrl) || '';
request.oauth2.clientId = _interpolate(request.oauth2.clientId) || '';
request.oauth2.clientSecret = _interpolate(request.oauth2.clientSecret) || '';
request.oauth2.scope = _interpolate(request.oauth2.scope) || '';
request.oauth2.credentialsPlacement = _interpolate(request.oauth2.credentialsPlacement) || '';
request.oauth2.tokenPlacement = _interpolate(request.oauth2.tokenPlacement) || '';
request.oauth2.tokenHeaderPrefix = _interpolate(request.oauth2.tokenHeaderPrefix) || '';
request.oauth2.tokenQueryKey = _interpolate(request.oauth2.tokenQueryKey) || '';
break;
default:
break;
}
}
if (request.awsv4config) {
request.awsv4config.accessKeyId = _interpolate(request.awsv4config.accessKeyId) || '';
request.awsv4config.secretAccessKey = _interpolate(request.awsv4config.secretAccessKey) || '';

View File

@@ -0,0 +1,6 @@
const { getOAuth2Token } = require('@usebruno/requests');
const tokenStore = require('./tokenStore');
module.exports = {
getOAuth2Token: (oauth2Config) => getOAuth2Token(oauth2Config, tokenStore)
};

View File

@@ -1,7 +1,7 @@
const { get, each, filter } = require('lodash');
const decomment = require('decomment');
const crypto = require('node:crypto');
const { mergeHeaders, mergeScripts, mergeVars, getTreePathFromCollectionToItem } = require('../utils/collection');
const { mergeHeaders, mergeScripts, mergeVars, mergeAuth, getTreePathFromCollectionToItem } = require('../utils/collection');
const { createFormData } = require('../utils/form-data');
const prepareRequest = (item = {}, collection = {}) => {
@@ -16,6 +16,7 @@ const prepareRequest = (item = {}, collection = {}) => {
mergeHeaders(collection, request, requestTreePath);
mergeScripts(collection, request, requestTreePath, scriptFlow);
mergeVars(collection, request, requestTreePath);
mergeAuth(collection, request, requestTreePath);
}
each(get(request, 'headers', []), (h) => {
@@ -31,6 +32,7 @@ const prepareRequest = (item = {}, collection = {}) => {
method: request.method,
url: request.url,
headers: headers,
name: item.name,
pathParams: request?.params?.filter((param) => param.type === 'path'),
responseType: 'arraybuffer'
};
@@ -72,6 +74,76 @@ const prepareRequest = (item = {}, collection = {}) => {
password: get(collectionAuth, 'digest.password')
};
}
if (collectionAuth.mode === 'oauth2') {
const grantType = get(collectionAuth, 'oauth2.grantType');
if (grantType === 'client_credentials') {
axiosRequest.oauth2 = {
grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
};
} else if (grantType === 'password') {
axiosRequest.oauth2 = {
grantType,
accessTokenUrl: get(collectionAuth, 'oauth2.accessTokenUrl'),
username: get(collectionAuth, 'oauth2.username'),
password: get(collectionAuth, 'oauth2.password'),
clientId: get(collectionAuth, 'oauth2.clientId'),
clientSecret: get(collectionAuth, 'oauth2.clientSecret'),
scope: get(collectionAuth, 'oauth2.scope'),
credentialsPlacement: get(collectionAuth, 'oauth2.credentialsPlacement'),
tokenPlacement: get(collectionAuth, 'oauth2.tokenPlacement'),
tokenHeaderPrefix: get(collectionAuth, 'oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(collectionAuth, 'oauth2.tokenQueryKey')
};
}
}
if (collectionAuth.mode === 'awsv4') {
axiosRequest.awsv4config = {
accessKeyId: get(collectionAuth, 'awsv4.accessKeyId'),
secretAccessKey: get(collectionAuth, 'awsv4.secretAccessKey'),
sessionToken: get(collectionAuth, 'awsv4.sessionToken'),
service: get(collectionAuth, 'awsv4.service'),
region: get(collectionAuth, 'awsv4.region'),
profileName: get(collectionAuth, 'awsv4.profileName')
};
}
if (collectionAuth.mode === 'ntlm') {
axiosRequest.ntlmConfig = {
username: get(collectionAuth, 'ntlm.username'),
password: get(collectionAuth, 'ntlm.password'),
domain: get(collectionAuth, 'ntlm.domain')
};
}
if (collectionAuth.mode === 'wsse') {
const username = get(collectionAuth, 'wsse.username', '');
const password = get(collectionAuth, 'wsse.password', '');
const ts = new Date().toISOString();
const nonce = crypto.randomBytes(16).toString('hex');
// Create the password digest using SHA-1 as required for WSSE
const hash = crypto.createHash('sha1');
hash.update(nonce + ts + password);
const digest = Buffer.from(hash.digest('hex').toString('utf8')).toString('base64');
// Construct the WSSE header
axiosRequest.headers[
'X-WSSE'
] = `UsernameToken Username="${username}", PasswordDigest="${digest}", Nonce="${nonce}", Created="${ts}"`;
}
console.log('axiosRequest', axiosRequest);
}
if (request.auth && request.auth.mode !== 'inherit') {
@@ -129,6 +201,56 @@ const prepareRequest = (item = {}, collection = {}) => {
password: get(request, 'auth.digest.password')
};
}
if (request.auth.mode === 'oauth2') {
const grantType = get(request, 'auth.oauth2.grantType');
if (grantType === 'client_credentials') {
axiosRequest.oauth2 = {
grantType,
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
};
} else if (grantType === 'password') {
axiosRequest.oauth2 = {
grantType,
username: get(request, 'auth.oauth2.username'),
password: get(request, 'auth.oauth2.password'),
clientId: get(request, 'auth.oauth2.clientId'),
clientSecret: get(request, 'auth.oauth2.clientSecret'),
scope: get(request, 'auth.oauth2.scope'),
accessTokenUrl: get(request, 'auth.oauth2.accessTokenUrl'),
tokenPlacement: get(request, 'auth.oauth2.tokenPlacement'),
credentialsPlacement: get(request, 'auth.oauth2.credentialsPlacement'),
tokenHeaderPrefix: get(request, 'auth.oauth2.tokenHeaderPrefix'),
tokenQueryKey: get(request, 'auth.oauth2.tokenQueryKey')
};
}
}
if (request.auth.mode === 'apikey') {
if (request.auth.apikey?.placement === 'header') {
axiosRequest.headers[request.auth.apikey?.key] = request.auth.apikey?.value;
}
if (request.auth.apikey?.placement === 'queryparams') {
if (axiosRequest.url && request.auth.apikey?.key) {
try {
const urlObj = new URL(request.url);
urlObj.searchParams.set(request.auth.apikey?.key, request.auth.apikey?.value);
axiosRequest.url = urlObj.toString();
} catch (error) {
console.error('Invalid URL:', request.url, error);
}
}
}
}
}
request.body = request.body || {};

View File

@@ -22,6 +22,7 @@ const path = require('path');
const { parseDataFromResponse } = require('../utils/common');
const { getCookieStringForUrl, saveCookies, shouldUseCookies } = require('../utils/cookies');
const { createFormData } = require('../utils/form-data');
const { getOAuth2Token } = require('./oauth2');
const protocolRegex = /^([-+\w]{1,25})(:?\/\/|:)/;
const { NtlmClient } = require('axios-ntlm');
const { addDigestInterceptor } = require('@usebruno/requests');
@@ -31,8 +32,7 @@ const onConsoleLog = (type, args) => {
};
const runSingleRequest = async function (
filename,
bruJson,
item,
collectionPath,
runtimeVariables,
envVariables,
@@ -43,14 +43,12 @@ const runSingleRequest = async function (
collection,
runSingleRequestByPathname
) {
const { pathname: itemPathname } = item;
const relativeItemPathname = path.relative(collectionPath, itemPathname);
try {
let request;
let nextRequestName;
let shouldStopRunnerExecution = false;
let item = {
pathname: path.join(collectionPath, filename),
...bruJson
}
request = prepareRequest(item, collection);
request.__bruno__executionMode = 'cli';
@@ -60,6 +58,7 @@ const runSingleRequest = async function (
// run pre request script
const requestScriptFile = get(request, 'script.req');
const collectionName = collection?.brunoConfig?.name
if (requestScriptFile?.length) {
const scriptRuntime = new ScriptRuntime({ runtime: scriptingConfig?.runtime });
const result = await scriptRuntime.runRequestScript(
@@ -71,7 +70,8 @@ const runSingleRequest = async function (
onConsoleLog,
processEnvVars,
scriptingConfig,
runSingleRequestByPathname
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
@@ -84,7 +84,7 @@ const runSingleRequest = async function (
if (result?.skipRequest) {
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: request.method,
@@ -98,7 +98,8 @@ const runSingleRequest = async function (
data: null,
responseTime: 0
},
error: 'Request has been skipped from pre-request script',
error: null,
status: 'skipped',
skipped: true,
assertionResults: [],
testResults: [],
@@ -116,6 +117,7 @@ const runSingleRequest = async function (
const options = getOptions();
const insecure = get(options, 'insecure', false);
const noproxy = get(options, 'noproxy', false);
const httpsAgentRequestFields = {};
if (insecure) {
httpsAgentRequestFields['rejectUnauthorized'] = false;
@@ -180,15 +182,22 @@ const runSingleRequest = async function (
const collectionProxyConfig = get(brunoConfig, 'proxy', {});
const collectionProxyEnabled = get(collectionProxyConfig, 'enabled', false);
if (collectionProxyEnabled === true) {
if (noproxy) {
// If noproxy flag is set, don't use any proxy
proxyMode = 'off';
} else if (collectionProxyEnabled === true) {
// If collection proxy is enabled, use it
proxyConfig = collectionProxyConfig;
proxyMode = 'on';
} else {
// if the collection level proxy is not set, pick the system level proxy by default, to maintain backward compatibility
} else if (collectionProxyEnabled === 'global') {
// If collection proxy is set to 'global', use system proxy
const { http_proxy, https_proxy } = getSystemProxyEnvVariables();
if (http_proxy?.length || https_proxy?.length) {
proxyMode = 'system';
}
} else {
proxyMode = 'off';
}
if (proxyMode === 'on') {
@@ -305,10 +314,45 @@ const runSingleRequest = async function (
}
}
let requestMaxRedirects = request.maxRedirects
request.maxRedirects = 0
// Set default value for requestMaxRedirects if not explicitly set
if (requestMaxRedirects === undefined) {
requestMaxRedirects = 5; // Default to 5 redirects
}
// Handle OAuth2 authentication
if (request.oauth2) {
try {
const token = await getOAuth2Token(request.oauth2);
if (token) {
const { tokenPlacement = 'header', tokenHeaderPrefix = 'Bearer', tokenQueryKey = 'access_token' } = request.oauth2;
if (tokenPlacement === 'header') {
request.headers['Authorization'] = `${tokenHeaderPrefix} ${token}`;
} else if (tokenPlacement === 'url') {
try {
const url = new URL(request.url);
url.searchParams.set(tokenQueryKey, token);
request.url = url.toString();
} catch (error) {
console.error('Error applying OAuth2 token to URL:', error.message);
}
}
}
} catch (error) {
console.error('OAuth2 token fetch error:', error.message);
}
// Remove oauth2 config from request to prevent it from being sent
delete request.oauth2;
}
let response, responseTime;
try {
let axiosInstance = makeAxiosInstance();
let axiosInstance = makeAxiosInstance({ requestMaxRedirects: requestMaxRedirects, disableCookies: options.disableCookies });
if (request.ntlmConfig) {
axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults)
delete request.ntlmConfig;
@@ -362,10 +406,10 @@ const runSingleRequest = async function (
responseTime = response.headers.get('request-duration');
response.headers.delete('request-duration');
} else {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: request.method,
@@ -374,13 +418,14 @@ const runSingleRequest = async function (
data: request.data
},
response: {
status: null,
status: 'error',
statusText: null,
headers: null,
data: null,
responseTime: 0
},
error: err?.message || err?.errors?.map(e => e?.message)?.at(0) || err?.code || 'Request Failed!',
status: 'error',
assertionResults: [],
testResults: [],
nextRequestName: nextRequestName,
@@ -392,12 +437,12 @@ const runSingleRequest = async function (
response.responseTime = responseTime;
console.log(
chalk.green(stripExtension(filename)) +
chalk.green(stripExtension(relativeItemPathname)) +
chalk.dim(` (${response.status} ${response.statusText}) - ${responseTime} ms`)
);
// run post-response vars
const postResponseVars = get(bruJson, 'request.vars.res');
const postResponseVars = get(item, 'request.vars.res');
if (postResponseVars?.length) {
const varsRuntime = new VarsRuntime({ runtime: scriptingConfig?.runtime });
varsRuntime.runPostResponseVars(
@@ -425,7 +470,8 @@ const runSingleRequest = async function (
null,
processEnvVars,
scriptingConfig,
runSingleRequestByPathname
runSingleRequestByPathname,
collectionName
);
if (result?.nextRequestName !== undefined) {
nextRequestName = result.nextRequestName;
@@ -438,7 +484,7 @@ const runSingleRequest = async function (
// run assertions
let assertionResults = [];
const assertions = get(bruJson, 'request.assertions');
const assertions = get(item, 'request.assertions');
if (assertions) {
const assertRuntime = new AssertRuntime({ runtime: scriptingConfig?.runtime });
assertionResults = assertRuntime.runAssertions(
@@ -475,7 +521,8 @@ const runSingleRequest = async function (
null,
processEnvVars,
scriptingConfig,
runSingleRequestByPathname
runSingleRequestByPathname,
collectionName
);
testResults = get(result, 'results', []);
@@ -500,7 +547,7 @@ const runSingleRequest = async function (
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: request.method,
@@ -516,16 +563,17 @@ const runSingleRequest = async function (
responseTime
},
error: null,
status: 'pass',
assertionResults,
testResults,
nextRequestName: nextRequestName,
shouldStopRunnerExecution
};
} catch (err) {
console.log(chalk.red(stripExtension(filename)) + chalk.dim(` (${err.message})`));
console.log(chalk.red(stripExtension(relativeItemPathname)) + chalk.dim(` (${err.message})`));
return {
test: {
filename: filename
filename: relativeItemPathname
},
request: {
method: null,
@@ -534,12 +582,13 @@ const runSingleRequest = async function (
data: null
},
response: {
status: null,
status: 'error',
statusText: null,
headers: null,
data: null,
responseTime: 0
},
status: 'error',
error: err.message,
assertionResults: [],
testResults: []

View File

@@ -0,0 +1,22 @@
// In-memory token store implementation for OAuth2 tokens
const tokenStore = {
tokens: new Map(),
// Save a token with optional expiry information
async saveToken(serviceId, account, token) {
this.tokens.set(`${serviceId}:${account}`, token);
return true;
},
// Get a token
async getToken(serviceId, account) {
return this.tokens.get(`${serviceId}:${account}`);
},
// Delete a token
async deleteToken(serviceId, account) {
return this.tokens.delete(`${serviceId}:${account}`);
}
};
module.exports = tokenStore;

View File

@@ -1,5 +1,47 @@
const axios = require('axios');
const { CLI_VERSION } = require('../constants');
const { addCookieToJar, getCookieStringForUrl } = require('./cookies');
const redirectResponseCodes = [301, 302, 303, 307, 308];
const METHOD_CHANGING_REDIRECTS = [301, 302, 303];
const saveCookies = (url, headers) => {
if (headers['set-cookie']) {
let setCookieHeaders = Array.isArray(headers['set-cookie'])
? headers['set-cookie']
: [headers['set-cookie']];
for (let setCookieHeader of setCookieHeaders) {
if (typeof setCookieHeader === 'string' && setCookieHeader.length) {
addCookieToJar(setCookieHeader, url);
}
}
}
};
const createRedirectConfig = (error, redirectUrl) => {
const requestConfig = {
...error.config,
url: redirectUrl,
headers: { ...error.config.headers }
};
const statusCode = error.response.status;
const originalMethod = (error.config.method || 'get').toLowerCase();
// For 301, 302, 303: change method to GET unless it was HEAD
if (METHOD_CHANGING_REDIRECTS.includes(statusCode) && originalMethod !== 'head') {
requestConfig.method = 'get';
requestConfig.data = undefined;
// Clean up headers that are no longer relevant
delete requestConfig.headers['content-length'];
delete requestConfig.headers['Content-Length'];
delete requestConfig.headers['content-type'];
delete requestConfig.headers['Content-Type'];
}
return requestConfig;
};
/**
* Function that configures axios with timing interceptors
@@ -7,10 +49,13 @@ const { CLI_VERSION } = require('../constants');
* @see https://github.com/axios/axios/issues/695
* @returns {axios.AxiosInstance}
*/
function makeAxiosInstance() {
function makeAxiosInstance({ requestMaxRedirects = 5, disableCookies } = {}) {
let redirectCount = 0;
/** @type {axios.AxiosInstance} */
const instance = axios.create({
proxy: false,
maxRedirects: 0,
headers: {
"User-Agent": `bruno-runtime/${CLI_VERSION}`
}
@@ -18,6 +63,15 @@ function makeAxiosInstance() {
instance.interceptors.request.use((config) => {
config.headers['request-start-time'] = Date.now();
// Add cookies to request if available and not disabled
if (!disableCookies) {
const cookieString = getCookieStringForUrl(config.url);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
config.headers['cookie'] = cookieString;
}
}
return config;
});
@@ -26,6 +80,8 @@ function makeAxiosInstance() {
const end = Date.now();
const start = response.config.headers['request-start-time'];
response.headers['request-duration'] = end - start;
redirectCount = 0;
return response;
},
(error) => {
@@ -33,6 +89,42 @@ function makeAxiosInstance() {
const end = Date.now();
const start = error.config.headers['request-start-time'];
error.response.headers['request-duration'] = end - start;
if (redirectResponseCodes.includes(error.response.status)) {
if (redirectCount >= requestMaxRedirects) {
// todo: needs to be discussed whether the original error response message should be modified or not
return Promise.reject(error);
}
const locationHeader = error.response.headers.location;
if (!locationHeader) {
// todo: needs to be discussed whether the original error response message should be modified or not
return Promise.reject(error);
}
redirectCount++;
let redirectUrl = locationHeader;
if (!locationHeader.match(/^https?:\/\//i)) {
const URL = require('url');
redirectUrl = URL.resolve(error.config.url, locationHeader);
}
if (!disableCookies){
saveCookies(redirectUrl, error.response.headers);
}
const requestConfig = createRedirectConfig(error, redirectUrl);
if (!disableCookies) {
const cookieString = getCookieStringForUrl(redirectUrl);
if (cookieString && typeof cookieString === 'string' && cookieString.length) {
requestConfig.headers['cookie'] = cookieString;
}
}
return instance(requestConfig);
}
}
return Promise.reject(error);
}

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