Compare commits

...

172 Commits

Author SHA1 Message Date
lohit-bruno
b74b76cc9a refactor(cache): remove unused getCacheStats and purgeCache IPC actions 2026-03-05 18:34:01 +05:30
lohit-bruno
f070812845 refactor(cache): rename httpHttpsAgents to sslSession across preferences and UI 2026-03-05 18:24:01 +05:30
lohit-bruno
adf5721ae0 refactor(cache): rename CLI flag to --cache-ssl-session and disable caching by default
- Rename --disable-http-https-agents-cache to --cache-ssl-session (opt-in)
- Rename disableHttpHttpsAgentsCache to cacheSslSession across CLI and bruno-requests
- Default caching to disabled in both bruno-electron and bruno-cli
- Add cacheSslSession to buildCertsAndProxyConfig for bru.sendRequest
- Update Preferences UI labels to "Cache SSL Session"
2026-03-05 17:18:11 +05:30
lohit-bruno
bad1a02116 fix(proxy): align proxy auth check to use auth.disabled field consistently 2026-03-05 16:58:40 +05:30
lohit-bruno
070c840e52 fix(tls): load client certs into secureContext to prevent silent drop
Add Cache tab to Preferences UI
2026-03-04 20:24:38 +05:30
lohit-bruno
41f3519dcc fix(proxy): fix proxy agent construction and CA cert handling
Three fixes:

1. Proxy agents (HttpsProxyAgent, HttpProxyAgent, SocksProxyAgent) expect
   (proxyUri, options) constructor signature, but the agent cache was packing
   proxyUri into options as a single argument. Fixed the non-timeline code
   path in getOrCreateAgentInternal.

2. HTTP requests through an HTTPS proxy need TLS options (ca certs) to
   validate the proxy's certificate. All getOrCreateHttpAgent call sites
   now pass TLS options when the proxy protocol is HTTPS.

3. Setting the `ca` option on any Node.js TLS connection replaces the
   default OpenSSL trust store entirely. CAs only in the OpenSSL default
   trust store (e.g. /etc/ssl/cert.pem) but not in tls.rootCertificates
   were lost. Fixed by converting `ca` to a secureContext via addCACert(),
   which appends custom CAs on top of the OpenSSL defaults instead of
   replacing them.

Also simplified PatchedHttpsProxyAgent to selectively forward only the
relevant TLS options (cert, key, pfx, passphrase, rejectUnauthorized,
secureContext) to the target TLS upgrade instead of blindly merging all
constructor options.
2026-03-04 19:34:04 +05:30
lohit-bruno
c4c0576660 fix: tests 2026-03-04 19:34:03 +05:30
lohit-bruno
594fc30f9f refactor: simplify UI labels, optimize agent timeline wrapping, silence proxy errors 2026-03-04 19:34:03 +05:30
lohit-bruno
8b08ba1ee9 refactor(cache): rename getOrCreateAgent to getOrCreateHttpsAgent 2026-03-04 19:33:42 +05:30
lohit-bruno
3619448d55 fix(cache): thread disableCache and hostname through all agent-creation paths
- Forward disableHttpHttpsAgentsCache through getHttpHttpsAgents → createAgents
  so OAuth2 token requests and bru.sendRequest honour the CLI flag
- Add hostname to agent cache keys (getAgentCacheKey, getHttpAgentCacheKey)
  for per-host TLS session reuse; extract hostname at every call site in
  run-single-request.js, proxy-util.js, and http-https-agents.ts
- Add extractHostname helper in http-https-agents.ts to safely parse hostnames
- Add test coverage for cert, key, pfx, passphrase, and hostname cache-key
  differentiation in agent-cache.spec.ts
2026-03-04 19:33:42 +05:30
lohit-bruno
b29bdc1e97 fix: tests 2026-03-04 19:33:42 +05:30
lohit-bruno
05bbb54df2 refactor(cache): replace window.ipcRenderer calls with redux actions
Add getCacheStats, purgeCache, and clearHttpHttpsAgentCache thunks to
the app slice. Update the Cache preferences component to dispatch these
actions instead of calling window.ipcRenderer directly.

Also move handleSave and handleSaveRef above useFormik to fix declaration
order — onSubmit closes over handleSaveRef, so the ref must be initialized
before useFormik is called.
2026-03-04 19:33:42 +05:30
lohit-bruno
795fb08d1f feat(cli): add --disable-http-https-agents-cache flag 2026-03-04 19:31:23 +05:30
lohit-bruno
0f05808886 feat(ui): add Cache tab to Preferences 2026-03-04 19:31:23 +05:30
lohit-bruno
592dd7d9e9 feat(redux): add cache.httpHttpsAgents preferences to initial state 2026-03-04 19:26:58 +05:30
lohit-bruno
a5ff9cf144 feat(ipc): add renderer:clear-http-https-agent-cache handler 2026-03-04 19:26:57 +05:30
lohit-bruno
93600b5da8 refactor(agent-cache): use named props for getOrCreateAgent and getOrCreateHttpAgent 2026-03-04 19:26:57 +05:30
lohit-bruno
0f1febc1fe feat(proxy-util): respect httpHttpsAgents cache preference 2026-03-04 19:26:57 +05:30
lohit-bruno
296612dcbc feat(agent-cache): add disableCache option to getOrCreateAgent 2026-03-04 19:26:57 +05:30
lohit-bruno
3e88cd6759 feat(preferences): add cache.httpHttpsAgents.enabled preference 2026-03-04 19:26:57 +05:30
lohit-bruno
37d1b3c5f9 feat(bruno-requests): log when reusing cached agent
- HTTPS agents: "Reusing cached agent (SSL session reuse enabled)"
- HTTP agents: "Reusing cached agent (connection reuse enabled)"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
15c86f8e6b fix(bruno-requests): address code review findings for agent caching
- Fix Buffer hashing bug: properly handle Buffer values in hashValue()
- Add CA array support: new hashCaValue() handles string[] | Buffer[]
- Fix timeline race condition: capture timeline reference in closure
  at createConnection start to isolate concurrent requests
- Fix SSL verify message: check socket.authorized for accurate status
- Fix HTTP/HTTPS agent logic: only set httpsAgent for HTTPS requests
- Add tests for concurrent requests timeline isolation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
14c66bc42f fix(bruno-electron): improve HTTP agent handling
- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Fix brace style consistency
- Add missing newline at EOF

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
f5a53319e0 fix(bruno-cli): improve HTTP agent handling and error logging
- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Add warning log for system proxy configuration errors
- Fix brace style consistency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
61a260f71c feat(bruno-requests): use HTTP agent cache for connection reuse
Export getOrCreateHttpAgent and use it in http-https-agents for
HTTP requests to enable connection pooling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
c6f3007dbf refactor(bruno-requests): extract shared agent caching logic
Add getOrCreateAgentInternal helper to reduce code duplication
between getOrCreateAgent and getOrCreateHttpAgent functions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
8605810747 feat(bruno-requests): add HTTP agent timeline support
Add createTimelineHttpAgentClass for logging HTTP connection events
including proxy usage, DNS lookups, and connection establishment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
c2bad2e2c8 feat(bruno-cli): use agent cache for SSL session reuse
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
dbc1d11e23 refactor(bruno-electron): use shared agent cache from bruno-requests
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
9df4b04ae8 feat(bruno-requests): integrate agent cache into http-https-agents
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
f51a7b2ded test(bruno-requests): add tests for agent cache
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
e2d3b4dbe8 feat(bruno-requests): add agent cache for SSL session reuse
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
lohit-bruno
28c4e24e2e feat(bruno-requests): add timeline agent for TLS event logging
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 19:26:57 +05:30
karthik
9cbc58df70 fix: enable SSL session caching for faster consecutive requests (#6929)
* fix: enable SSL session caching for faster consecutive requests

Previously, Bruno created a new HTTPS agent for every request, which meant
SSL/TLS sessions couldn't be reused. This caused the full TLS handshake
(~450ms) to run on every request, even to the same endpoint.

Changes:
- Add agent caching based on TLS configuration (certs, proxy, SSL options)
- Reuse cached agents for requests with matching configuration
- SSL sessions are now cached and reused, significantly reducing
  response time for consecutive requests to the same host

The fix maintains backward compatibility:
- Timeline logging moved to setup phase (before agent creation)
- Proxy and SSL validation behavior unchanged
- Added clearAgentCache() for testing and configuration changes

Fixes #5574

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address review feedback for SSL session caching

- Add passphrase to cache key to prevent incorrect agent reuse
- Add MAX_AGENT_CACHE_SIZE (100) with LRU-style eviction
- Use consistent node: prefix for crypto import

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: lohit <lohit@usebruno.com>
2026-03-04 19:26:57 +05:30
Chirag Chandrashekhar
0b7cd0e540 Revert "Performance/file parse and mount (#6975)" (#7360)
* Revert "Performance/file parse and mount (#6975)"

This reverts commit f76f487211.

* fix: import duplication

* Revert "fix(batch-events): fix order of directory file and folder events (#7300)"

This reverts commit bf4af42a25.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-03-04 19:20:26 +05:30
sanish chirayath
17c3dc0e2b refactor: comment out unused APIs (#7323)
* refactor: comment out unused API hints in autocomplete.js

* refactor: comment out unused API translations in postman and bruno translators

Temporarily disable certain API translations due to UI update issues affecting their functionality. A note has been added to restore these translations once the UI fixes are implemented.

* refactor: temporarily skip tests for collection variable translations due to UI update issues

Commented out tests related to `setCollectionVar`, `deleteCollectionVar`, and related functionalities until the necessary UI updates are implemented. A note has been added to restore these tests once the fixes are live.

* refactor: comment out variable deletion and retrieval methods due to UI sync issues

* revert: ping.bru

* refactor: update postman translation tests to enable previously skipped cases
2026-03-04 18:00:10 +05:30
Bijin A B
75c3ab8032 chore: update coderabbit instructions to make sure the code is os agnostic (#7355) 2026-03-04 13:50:08 +05:30
gopu-bruno
6d86c76b21 feat: inline create collection and workspace editor (#7324)
* feat: inline create collection and workspace editor

* refactor: use inline collection creation from workspace overview

* fix: improve inline collection creation UX from workspace overview

* fix: update E2E tests for inline collection creation flow

* fix: update default location test for inline collection creation flow

* fix: derive inline workspace/collection names from filesystem

* feat: inline workspace create form manage workspace

* feat: prefill create modal with name

* fix: minor code style fixes

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
2026-03-04 13:25:30 +05:30
Niklas Krebs
7218c66d5a Fix persistence of additional parameters using open-collection format (#7296)
Co-authored-by: TaylorJerry <47517046+TaylorJerry@users.noreply.github.com>
Co-authored-by: MrcBoo <63651816+MarcBoo@users.noreply.github.com>
2026-03-04 10:14:23 +05:30
Abhishek S Lal
e0dd79418b feat: enhance API spec export with environment variables support (#7170)
* feat: enhance API spec export with environment variables support

- Updated `exportApiSpec` function to accept and process environment variables for multi-server exports.
- Added logic to convert environment variables into a structured format for OpenAPI server entries.
- Enhanced the `CreateApiSpec` component to include environments in the exported YAML content.
- Introduced unit tests to validate the handling of server variables and their integration into the exported API specifications.

* refactor: streamline API spec export logic and improve variable handling

- Simplified variable extraction in `exportApiSpec` by directly assigning capture groups.
- Updated URL interpolation to use request variables instead of global variables for better accuracy.
- Enhanced handling of request body types by replacing early returns with breaks for clearer flow control.
- Adjusted tests to ensure backward compatibility with OpenAPI specifications and server variable handling.

* refactor: improve variable handling and URL processing in OpenAPI exporters

- Streamlined server variable assignment in `exportApiSpec` to handle undefined values more gracefully.
- Enhanced URL path extraction to ensure leading slashes are preserved in `getDefaultUrl` and `extractServerVars`.
- Updated string replacement logic to use `replaceAll` for consistent variable substitution in URLs.
2026-03-03 21:24:01 +05:30
Chirag Chandrashekhar
574324e784 feat: add collection creation flow in SaveTransientRequest modal (#7328) 2026-03-03 19:24:20 +05:30
Pooja
caf073c185 fix: file extension for clone and rename request (#7278)
* fix: file extension for clone and rename request

* fix
2026-03-03 19:12:59 +05:30
shubh-bruno
14532b48a6 feat(phase-1): allow user to customize keybindings (#7163)
* feat(phase-1): allow user to customize keybindings

* fix: necessary changes for customizied keybindings to work

* fix: updated hotkeys provider

* fix: test cases for edit keybindings in preferences

* fix: removed old keyboard shortcuts test cases

* fix: resolved coderabbit coments

* fix: fixed move tabs test cases

* feat: provided customized keybindings shorcut for codemirror instacnces

* fix: handle closetabs/closeAllTabs in RequestTab/index.js for better consitency

* fix: resolved comments

* fix: resolved zoom issues

* fix: revert codemirror instacnces

* fix: handle codemirror instances shortcut in .Pass

* feat: integrate shorcut keys with codemirror instacneces

* fix: ui updates

* fix: updated shortcuts

* fix: test cases

* chore: revert `alt-enter` keybind

* chore: allow jest to replace esm spec in store

* chore: lint whitespace fix

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-03-03 18:49:18 +05:30
naman-bruno
e42b015867 refactor: streamline onboarding process and preferences handling (#7349)
* refactor: streamline onboarding process and preferences handling

* fixes

* fixes
2026-03-03 16:41:42 +05:30
gopu-bruno
e159a442d0 feat(sidebar): show "Add request" cta when collection or folder is empty (#7273)
* feat(sidebar): show "Add request" cta when collection or folder is empty

* fix: add connector lines and keyboard accessibility to empty-state CTA

* fix: debounce empty collection state to prevent flicker

Add 300ms delay before showing "Add request" button for empty collections.
This fixes a race condition where isLoading becomes false before the
items batch arrives from IPC, causing a brief flicker of the empty state.

Made-with: Cursor

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
2026-03-03 16:40:42 +05:30
naman-bruno
4b15b14cf7 feat: add functionality to create new HTTP requests from the welcome modal and collections section (#7350) 2026-03-03 14:42:39 +05:30
shubh-bruno
ca0412b58b fix: allow user to delete default bruno headers in pre-request (#7331)
* fix: allow user to delete default bruno headers

* fix: resolved comments

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-03-03 14:35:54 +05:30
lohit
bba0e97435 fix: ensure system proxy is initialized before use in network calls (#7264)
getCachedSystemProxy now awaits the initialization promise, preventing a race
condition where API calls made early in startup would bypass the system proxy.
2026-03-03 14:28:12 +05:30
shubh-bruno
834a4fe020 fix: resolved zoom ui remarks (#7326)
* fix: resolved zoom ui remarks

* fix: resolved comments

* fix: resolved comments

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-03-03 13:28:06 +05:30
gopu-bruno
910581a627 feat: improve stack traces for script and test failures (#7181)
* chore: update package-lock.json to include yaml dependency

* feat: add script-aware stack traces and source context for script/test failures

* chore: sync package-lock.json with yaml dependency in bruno-js

* fix: handle null check in getErrorTypeName and align JSDoc style

* refactor: derive script path from request.pathname and use SCRIPT_TYPES constant

* fix: avoid showing source context for collection/folder script errors

* feat: map collection/folder script errors to source file and line

* fix: update error formatting and avoid undefined message

* fix: resolve script block location in collection/folder yml files

* refactor: use script wrapper utils and rename wrapper offsets

* refactor: move script wrapper to utils, add wrapScriptInClosure fn
2026-03-02 15:27:18 +05:30
lohit
4797abbeff feat: add tokenType support for OAuth2 (#7314)
* feat: add tokenType support for OAuth2

* refactor: rename tokenType to source in OpenCollection OAuth2 mapping

* refactor: rename tokenType to source in OAuth2 configuration

* chore: bump @opencollection/types to ~0.8.0

* fix: correct OAuth2 token type label in token viewer

* refactor: replace Dropdown with MenuDropdown in OAuth2 components

Migrate all 12 dropdown instances across 5 OAuth2 auth components to use
the MenuDropdown component, removing manual tippy ref management and
forwardRef icon patterns in favor of a declarative items-based API.
2026-02-27 20:50:23 +05:30
lohit
4f4faec359 fix(oauth2): prevent false callback matches on root path URLs (#7315)
* fix(oauth2): prevent false callback matches on root path URLs and handle errors first

Move error check before callback URL matching in onWindowRedirect so
OAuth error responses are rejected immediately. Remove redundant error
param check from matchesCallbackUrl and require a code param or hash
fragment to match, preventing false positives on intermediate pages
when the callback URL is a root path like https://hostname/.

* fix(oauth2): clarify error handling comment

Remove "on the callback URL" from comment since error checking
now happens before callback URL matching.
2026-02-27 20:48:51 +05:30
Chirag Chandrashekhar
a9709fb82a fix: Postman import compatibility for multipart form-data file params (#7325)
* fix: multipart form-data file param export/import for Postman

* fix: Postman import compatibility for multipart form-data file params

This commit fixes two issues that caused Postman to fail importing
Bruno-exported collections with multipart form-data file parameters:

1. Changed `src` field format to match Postman's export format:
   - Single file: export as string (e.g., "/path/to/file")
   - Multiple files: export as array (e.g., ["/path/a", "/path/b"])
   - Empty/null: export as null

   Previously, Bruno always exported `src` as an array, but Postman's
   importer expects a string for single files and fails to recognize
   the file type and path when given an array.

2. Added `protocolProfileBehavior.disableBodyPruning` for GET/HEAD/OPTIONS
   requests that have a body:

   By default, Postman discards request bodies for HTTP methods that
   typically don't have bodies (GET, HEAD, OPTIONS). Without this flag,
   importing a GET request with a form-data body would result in the
   body being silently dropped, making Postman unable to identify that
   the request has a formdata body at all.

Both changes align Bruno's Postman export format with Postman's own
export format, ensuring full import compatibility.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-02-27 17:30:20 +05:30
Melroy van den Berg
5e75bc5fcb Update copyright year to 2026 (#7302)
* Update copyright year to 2026

* Update footer year to current year dynamically
2026-02-27 16:24:16 +05:30
naman-bruno
c8e57b7f9f feat: implement onboarding preferences and welcome modal for new users (#7319)
* feat: implement onboarding preferences and welcome modal for new users

* fixes

* adding: defaultPreferences

* fixes

* fix: tests

* fixes

* fix: test

* fix: test

* fixes

* fixes
2026-02-27 16:15:06 +05:30
sanish chirayath
8b230043c1 Enable encodeUrl setting to control URL encoding in generated snippets (#7187)
* feat(snippet-generator): implement encodeUrl setting to control URL encoding in generated snippets

* refactor(snippet-generator): rename and enhance URL encoding logic for better clarity and functionality

* feat(snippet-generator): enhance raw URL handling to preserve user encoding choices and improve snippet generation

* test(snippet-generator): add tests for URL fragment handling based on encodeUrl setting

* test(snippet-generator): improve comments on URL fragment handling to clarify RFC compliance

* feat(url): enhance interpolateUrlPathParams to support raw URL handling, preserving user encoding choices for snippet generation

* fix(url): ensure URLs are prefixed with http:// if missing in interpolateUrlPathParams function

* refactor(snippet-generator): streamline URL handling logic to improve snippet generation and ensure proper encoding based on settings

* feat(url): add stripOrigin utility to simplify URL processing in snippet generation

* test(snippet-generator): add test for double-encoding of pre-encoded URLs when encodeUrl is true

* feat(encoding): implement URL encoding settings and add tests for encoding behavior

* fix: address PR review comments (#7187)

- Remove unnecessary no-op jest.mock for @usebruno/common/utils
- Add length guard to prevent catastrophic replaceAll('/') on root-path URLs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* empty commit

* fix(tests): update interpolateUrlPathParams tests to use correct parameter structure

* empty commit

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:46:24 +05:30
Sanjai Kumar
5dd684f7a3 fix: wrong workspace request shown after closing tab (#7259)
* feat: add ensureActiveTabInCurrentWorkspace action and improve tab management

* refactor: enhance tab focus logic for current workspace and improve tab management

* tests: implement test for getTabToFocusForCurrentWorkspace logic and added a playwright

* refactor: improve comments for tab management logic and enhance workspace tab focus handling in tests

* refactor: ensure active tab in current workspace after collection removal and enhance tests for tab focus logic

* trigger build
2026-02-27 15:38:13 +05:30
austenadler
27e22bd857 Force text/plain mimetype when copying request code (#7321)
* Force text/plain mimetype when copying requests

* chore: lint

---------

Co-authored-by: Austen Adler <agadler@austenadler.com>
2026-02-27 14:11:04 +05:30
shubh-bruno
7a652503b6 fix: tags validation error for openapi import for BRU and YAML compatibility (#7294)
* fix: tags schema updatd to array of string

* tests: added test cases for sanitizing tags, openapi-tags

* fix: enhance tag sanitization to support object input and UTF characters

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-02-27 14:04:28 +05:30
Sid
bf4af42a25 fix(batch-events): fix order of directory file and folder events (#7300)
* fix: order of events

* fix: update constants handling in CollectionTreeBatcher and related tests
2026-02-27 13:43:15 +05:30
naman-bruno
39d6999cb2 fix: prevent triggering rename action with modifier keys (#7322) 2026-02-27 12:56:39 +05:30
Nikhil
3fdb81849c fix#6247: Interpolate dynamic variables in path param (#6251) 2026-02-27 12:21:32 +05:30
Miro Metsänheimo
fcfb7d409c fix(schema): support all Unicode letters in tag validation (#7311)
* allow international characters in tag regex for UI and schema
  validation
* update validation messages to match this

Co-authored-by: Miro Metsänheimo <miro.metsanheimo@cgi.com>
2026-02-26 21:56:02 +05:30
Chirag Chandrashekhar
da1d7e51d2 fix(graphql): handle invalid schemas gracefully in query editor (#7269)
Prevent app crashes when loading GraphQL schemas with validation errors
(e.g., object types with no fields). The fix:

- Validates schemas using validateSchema() and shows warnings for issues
- Still loads the schema so autocomplete continues to work
- Wraps the CodeMirror GraphQL linter with error handling to catch
  any validation errors during linting

Fixes #4529

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-02-26 18:29:19 +05:30
Pragadesh-45
b0d0e4aabc Feat: Support multipart/mixed (#7155)
* feat(): support multipart mixed

fix: support vars interpolation on mixed multi-part

Update packages/bruno-electron/src/ipc/network/interpolate-vars.js

Co-authored-by: Timon <39559178+Its-treason@users.noreply.github.com>

refactor: use startsWith

feat: best effort for other multipart/* contentypes

* feat: enhance variable interpolation for multipart requests

- Updated `interpolateVars` function to support interpolation in multipart/form-data and multipart/mixed requests.
- Added handling for empty multipart arrays and parts with missing or undefined values.
- Improved type checks for content types to ensure proper interpolation behavior.

Includes new tests to validate the interpolation functionality for multipart requests.

* fix: normalize error handling in sendRequest and improve test reliability

---------

Co-authored-by: Alfonso Presa <alfonso-presa@users.noreply.github.com>
2026-02-26 17:43:37 +05:30
shubh-bruno
234d0df449 fix: storing status in example for yml file (#6876)
* fix: storing status in example for yml file

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: test cases for status and statusText

* chore: removed logs

* fix: test cases for response status and text

* fix: test cases for response status and text

* fix: resolved comments

* fix: openapi test import test cases

* chore: removed console logs

* fix: status type in response example while import/export of collection

* fix: postman to bruno import

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-02-26 17:33:02 +05:30
gopu-bruno
8ce38e8480 feat: change default collection location to default location (#7291)
* feat: change default collection location to default location

* refactor: migrate defaultCollectionLocation to defaultLocation in preference.json

* refactor: resolveDefaultLocation function

* fix: rename variables in default-location
2026-02-26 16:10:56 +05:30
Pragadesh-45
4d61ecacb3 Fix Environment Search Behavior, UI Updates, and Result Handling (#7287) 2026-02-26 12:27:03 +05:30
Chirag Chandrashekhar
81a7544853 feature: added support for unix sockets and named pipes(windows) for grpc local IPC support (#7021) 2026-02-25 19:18:33 +05:30
Chirag Chandrashekhar
f76f487211 Performance/file parse and mount (#6975)
* Refactor: optimize collection updates with batch processing

- Introduced BatchAggregator to handle IPC events in batches, reducing Redux dispatch overhead during collection mounting.
- Updated collection watcher to utilize batch processing for adding files and directories, improving UI performance.
- Implemented ParsedFileCacheStore using LMDB for efficient caching of parsed file content, enhancing loading speed and reducing redundant parsing.
- Adjusted collection slice to support batch addition of items, minimizing re-renders and improving state management.
- Updated relevant components to reflect changes in loading states and collection data handling.

* feat: add cache management to preferences

- Introduced a new Cache component in the Preferences section to display cache statistics and allow users to purge the cache.
- Implemented IPC handlers for fetching cache stats and purging the cache in the Electron main process.
- Added styled components for better UI presentation of cache information.
- Updated Preferences component to include a new tab for cache management.

* fix: update package-lock.json to change 'devOptional' to 'dev' for several Babel dependencies

* refactor: update batch aggregation parameters for improved performance

- Increased DISPATCH_INTERVAL_MS from 150ms to 200ms for better timing control.
- Adjusted MAX_BATCH_SIZE from 200 to 300 items to enhance batch processing efficiency.

* feat: enhance collection loading state and improve batch aggregator functionality

- Added isLoading property to collections slice to manage loading state during collection operations.
- Updated getAggregator function calls in collection-watcher to include collectionUid for better context in batch processing.
- Normalized directory path handling in parsed-file-cache to ensure consistent prefix creation for cache keys.

* fix: update loading state and transient file handling in collections slice

- Changed isLoading property to false during collection initialization for accurate loading state representation.
- Introduced isTransient flag for directories and files to differentiate between transient and non-transient items.
- Enhanced logic for handling transient directories and files during collection processing to improve state management.

* feat: add batch processing support for file additions in task middleware

- Implemented a new listener for collectionBatchAddItems to handle batch file additions.
- Enhanced task management by checking for pending OPEN_REQUEST tasks that match added files.
- Improved tab management by dispatching addTab actions for matching files and removing corresponding tasks from the queue.

* feat: enable ASAR packaging and unpacking for LMDB binaries in Electron build configuration

- Added ASAR support to the Electron build configuration for the Bruno application.
- Specified unpacking rules for LMDB native binaries to ensure proper loading during runtime.

* feat: implement parsed file cache using IndexedDB for improved performance

- Introduced a new `parsedFileCacheStore` utilizing IndexedDB for caching parsed file data.
- Replaced the previous LMDB-based cache implementation to enhance performance and reliability.
- Updated IPC handlers to manage cache operations such as get, set, invalidate, and clear.
- Integrated the new cache store into various components, ensuring efficient data retrieval and storage.
- Added pruning functionality to remove outdated cache entries on startup.

* refactor: update collection root and item handling to preserve UIDs

- Modified the way collection roots and folder items are assigned by using `mergeRootWithPreservedUids` and `mergeRequestWithPreservedUids` to ensure UIDs are maintained during updates.
- This change enhances data integrity when managing collections and their associated files.

* refactor: pass mainWindow reference to parsedFileCacheStore initialization

- Updated the `initialize` method in `ParsedFileCacheStore` to accept a `mainWindow` parameter, allowing for direct access to the main window instance in IPC handlers.
- This change improves the handling of IPC requests by ensuring the correct window context is used for sending messages.

* refactor: optimize getStats method in parsedFileCacheStore for performance

- Replaced manual counting of total files with a direct count() call for O(1) performance.
- Updated the collection counting logic to utilize openKeyCursor with 'nextunique' for improved efficiency in counting unique collection paths.
- These changes enhance the performance of the getStats method by reducing the complexity of file and collection counting.

* fix: update key generation in parsedFileCache to use newline separator

- Changed the key generation logic in `generateKey` from a null character to a newline character for improved readability and consistency in cache keys.

* refactor: rename batch-aggregator to collection-tree-batcher and add tests

- Rename BatchAggregator class to CollectionTreeBatcher
- Rename getAggregator/removeAggregator to getBatcher/removeBatcher
- Update imports and variable names in collection-watcher.js
- Add backward-compatible aliases for old names
- Add 22 unit tests covering all functionality

* refactor: update key generation in parsedFileCache to use custom separator

- Changed the key generation logic in `generateKey` to use a custom separator (↝) instead of a newline character for improved readability and consistency in cache keys.

* fix: add missing reject handler and fix directory prefix collision

- Add reject to Promise and pendingRequests in parsed-file-cache-idb.js
- Normalize dirPath with trailing separator in invalidateDirectory to
  prevent false matches (e.g., /foo/bar matching /foo/barley)
- Use platform-specific path.sep for cross-platform compatibility

* fix: add error handling in parsedFileCache and update window close event

- Added a catch block to handle errors in the database promise in parsedFileCache.
- Updated the window close event listener in collection-tree-batcher to use `once` for better resource management.

* fix: add LRU eviction when IndexedDB quota is exceeded

Handle QuotaExceededError in setEntry by automatically evicting
the oldest 20% of cache entries and retrying the write operation.

* fix: use once instead of on in mock window for batcher tests

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-02-25 19:15:48 +05:30
lohit
d3da8a3021 fix: default normalizeProxyUrl to http protocol for all proxy URLs (#7285) 2026-02-25 17:31:05 +05:30
Abhishek S Lal
757b635b0d feat: add options to skip request and response bodies in reporter output (#7114)
* feat: add options to skip request and response bodies in reporter output

- Introduced `--reporter-skip-request-body` and `--reporter-skip-response-body` flags to omit respective bodies from the reporter output.
- Updated examples in the CLI documentation to reflect new options.
- Refactored result sanitization to handle new flags.

* feat: add shorthand option to skip both request and response bodies in reporter output

- Introduced `--reporter-skip-body` as a shorthand for omitting both request and response bodies from the reporter output.
- Updated CLI documentation examples to include the new shorthand option.
- Adjusted result sanitization to accommodate the new option.

* refactor: simplify documentation and tests for reporter-skip-body option

- Updated the description of the `--reporter-skip-body` option to remove redundancy.
- Removed outdated shorthand references from the test suite for clarity.
- Cleaned up examples in the CLI documentation to focus on the current functionality.

* fix: handle optional chaining for request and response properties in result sanitization

- Updated the `sanitizeResultsForReporter` function to use optional chaining when accessing request and response headers and data.
- This change prevents potential errors when these properties are undefined.

* test: enhance reporter-skip-body tests for JSON and HTML outputs

- Added comprehensive tests for the `--reporter-skip-request-body` and `--reporter-skip-response-body` options in both JSON and HTML report formats.
- Verified that the appropriate request and response bodies are included or excluded based on the specified flags.
- Improved test coverage for scenarios where both flags are used simultaneously.

* fix: remove optional chaining for request and response headers in result sanitization

- Updated the `sanitizeResultsForReporter` function to directly assign empty objects to request and response headers, ensuring consistent behavior regardless of their initial state.
- This change simplifies the code and maintains functionality for skipping headers.
2026-02-25 16:35:48 +05:30
Timon
0045b16e06 perf: Improve search performance in code editor (#6920)
* perf: Improve search performance in code editor

Added cache key to reduce duplicate searches over the complete text.
Added incremental update logic, to not update every marked entry when
clicking "next" or "previous".

Fixes: https://github.com/usebruno/bruno/issues/6913

* Update delimeter to unicode symbol
2026-02-25 14:37:00 +05:30
sanish chirayath
e950640205 fix: skip null query parameters in Postman to Bruno conversion (#7193)
* fix: skip null query parameters in Postman to Bruno conversion

Updated the importPostmanV2CollectionItem function to skip query parameters where both key and value are null. Added a test case to ensure that such parameters are not included in the converted Bruno collection, while preserving other valid parameters.

* fix: skip null parameters in Postman to Bruno conversion

Updated the importPostmanV2CollectionItem function to skip headers, URL-encoded parameters, and form data where both key and value are null. Added corresponding test cases to ensure proper handling of these scenarios in the conversion process.
2026-02-25 13:30:26 +05:30
Pooja
ce15fbb6df fix: phone number faker function (#7046) 2026-02-25 12:13:46 +05:30
naman-bruno
8d301df329 fix: normalize collection pathnames in EnvironmentSecretsStore (#7283)
* fix: normalize collection pathnames in EnvironmentSecretsStore

* fix

* fix
2026-02-25 01:20:31 +05:30
Pooja
5c0a49af10 fix: handle special characters in collection path for dotenv watcher (#7190)
* fix: handle special characters in collection path for dotenv watcher

* fix
2026-02-25 00:00:10 +05:30
Sanjai Kumar
ade4bfb7e1 fix: response viewer not updating when focused (read-only editors) (#7218)
* fix: update cursor state handling in CodeEditor for read-only mode

* tests: add response pane update test for re-sent requests

* refactor: add data-testid attributes for improved testing in RequestBody and RequestBodyMode components; update locators and tests accordingly

* test: verify response status code is 200

* test: enhance response pane update tests to verify body editor content after request re-sent

* test: add data-testid for method selector and update locators for improved testability
2026-02-23 20:21:43 +05:30
Bijin A B
4e2303ecf3 chore: fix tab selection (#7260) 2026-02-23 19:51:58 +05:30
Pooja
5bca0cdd84 fix: openapi cli import (#7028)
* fix: openapi cli import

* chore: seperate bru and opencollection by a flag

* fix: pass down format correctly

* fix: pass format option correctly in collection tests

* Add opencollection version for YAML format

Set opencollection version for YAML format.

---------

Co-authored-by: Sid <siddharth@usebruno.com>
2026-02-23 18:53:03 +05:30
shubh-bruno
89bf2fbf44 feat: interface zoom control settings (#7255)
* feat: interface zoom control settings

* fix: allow zoom controls using shortcuts

* fix: maintain consitency in zoom shortcuts and ui interface

* fix: added min max to 50% and 150% for zoom

* fix: moved percentageToZoomLevel function in bruno-common

* chore: abstractions

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-02-23 16:32:34 +05:30
Bijin A B
04ef477f3b feat(CI): refactor github workflow for tests (#7252) 2026-02-22 16:51:10 +05:30
naman-bruno
689e0c6573 fix: window normlize path comparison (#7240) 2026-02-20 16:04:17 +05:30
Sanjai Kumar
71227224dd fix: collection reorder not persisting after restart (#7093)
* feat: implement workspace collection reordering functionality

feat: add tests and improve collection reordering functionality

fix: handle IPC errors during collection reordering and update tests for persistence validation

refactor: enhance collection reordering logic and update related tests for path consistency

fix: prevent unnecessary promise resolution in collection reordering when no collections are present

* fix: ensure consistent path normalization for workspace collections during reordering
2026-02-20 14:00:44 +05:30
shubh-bruno
dfa1533b72 fix: updated error message for renaming requests (#7010)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-02-20 12:13:54 +05:30
lohit
cd33cb76fb fix: call initializeShellEnv directly in app ready handler (#7228)
Move the shell environment initialization from module-level into the
app ready callback, avoiding a dangling promise at import time.
2026-02-20 01:28:22 +05:30
Ngô Quốc Đạt
0376d38860 feat: add reveal in file manager option to workspace collections menu (#6944) 2026-02-19 21:25:39 +05:30
lohit
6ea079f6b1 fix: load shell environment variables on app startup (#7223)
Add shell-env integration to fetch environment variables from the user's
shell config files (.zshrc, .zshenv, etc.) so that proxy settings and
other exports are available in process.env for both Electron and CLI.
2026-02-19 21:17:27 +05:30
lohit
2fcfdfc338 fix: oauth2 credential management improvements (#7220)
* fix: oauth2 credential management improvements

Add bru.resetOauth2Credential() API for programmatic credential invalidation
from scripts, fix credential clearing to match on credentialsId, expose
oauth2 credential variables in test runtime, and add input validation
with deduplication to prevent redundant IPC messages. Remove unused
collectionGetOauth2CredentialsByUrlAndCredentialsId reducer.

* fix: handle invalid URLs in oauth2 callback redirect handler

Wrap new URL() calls in try-catch within onWindowRedirect to prevent
uncaught TypeError when redirect or callback URLs are invalid.

* Update packages/bruno-app/src/utils/codemirror/autocomplete.js

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-19 21:11:28 +05:30
Ram
09b8e8a32a fix(cli): preserve request item type during import and fail on unsupported types (#7207)
* fix(cli): preserve request type when importing collections

* fix(cli): fail fast on unsupported imported item types

* fix(cli): force BRU format when writing imported files

* chore: apply code rabbit fixes

* chore: adress review comment - keep changes minimal

* add additional test to test bru folder format

* agree with coderabbit, error handling is required

---------

Co-authored-by: Ramesh Sunkara <rs@rsunkara.com>
2026-02-19 20:43:44 +05:30
Mickael V
d060544da6 Prevent cursor state loss on empty nextValue (#7180) 2026-02-19 17:23:05 +05:30
Bastien Dumont
d35394c714 fix: consistent button size on save requests modal (#7197) 2026-02-19 16:55:20 +05:30
sanish chirayath
3c585a30b7 Fix: incorrect translations (#7214)
* feat: update req.setHeader translation to use pm.request.headers.add with object argument

empty commit

* refactor: update req.setHeader translation to use pm.request.headers.upsert
2026-02-19 15:53:42 +05:30
Pooja
ab2a16ac05 fix: env var edit (#7066) 2026-02-19 14:59:53 +05:30
shubh-bruno
cb716e5978 fix: import ndjson-curl handling binary data check with type file or not (#7210)
* fix: import ndjson-curl handling binary data check with type file

* chore: handle ansi escaping manually

* chore: avoid duplicate replacements

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-02-19 14:56:56 +05:30
Pooja
c093354938 fix: collection scope undefined var (#7211) 2026-02-19 13:40:05 +05:30
naman-bruno
b0a88bf00c fix: normalize Windows paths for cross-platform compatibility (#7185)
* fix: normalize Windows paths for cross-platform compatibility in workspace

* fixes
2026-02-18 17:32:58 +05:30
Chirag Chandrashekhar
8b80166170 fix: change transient request directory from os temp to app data (#7184)
* fix: change transient request directory from os temp to app data

Move transient request files from os.tmpdir() to app.getPath('userData')/transient
to prevent data loss when the OS clears temporary files.

Changes:
- Add helper functions for transient directory paths
- Update findCollectionPathByItemPath to use new transient path check
- Update renderer:mount-collection to create temp dirs in app data
- Update renderer:mount-workspace-scratch to create temp dirs in app data
- Update renderer:delete-transient-requests prefix validation

* change transient directory path to userData/tmp/transient

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
Co-authored-by: naman-bruno <naman@usebruno.com>
2026-02-18 17:30:27 +05:30
lohit
479fc160d7 fix: isJson assertion fails after res.setBody() with object in node-vm (#7191)
* fix: isJson assertion fails after res.setBody() with object in node-vm

Objects created inside Node's vm.createContext() have a different Object
constructor than the host realm. When res.setBody() is called with a JS
object from a script, _.cloneDeep preserves the cross-realm prototype,
causing obj.constructor === Object to fail in the isJson assertion.

Replace with Object.prototype.toString.call() which is cross-realm safe.

* fix: register isJson chai assertion in QuickJS test runtime

The bundled chai in QuickJS only exposes { expect, assert } via
requireObject — no Assertion class. Access the prototype through
Object.getPrototypeOf(expect(null)) and use Object.defineProperty
to register the json property directly.

* fix: enable assertion chaining on isJson in QuickJS runtime

The QuickJS isJson property getter was missing `return this`, preventing
chai assertion chaining (e.g. expect(body).to.be.json.and...).
2026-02-18 17:24:08 +05:30
tobiasgjerstrup
2337d77092 feature/f2-rename-shortcut (#7077)
* feature/f2-rename-shortcut

* feat(hotkeys): added OS-specific key bindings for renaming items

* fix(hotkeys): updated hotkey function call to somethhing more performant and standard
2026-02-17 19:17:43 +05:30
Joren-vanGoethem
2e58621759 update redux state to keep currently active script tab in state (#6947) 2026-02-17 19:02:20 +05:30
Bijin A B
78c629e7a6 chore(playwright): enhance playwright config to reduce flakiness (#7174) 2026-02-17 18:57:39 +05:30
Jeroen Vinke
03f7e60c66 fix cookies not being set when follow redirect = false (#6679)
* fix follow redirect cookies not being set

* Set default value of followredirects in axios-instance

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

---------

Co-authored-by: Jeroen Vinke <jeroen.vinke@iddinkgroup.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-17 18:41:14 +05:30
Evgeniy
540bb706e5 fix: parsing dates from insomnia (#7003)
js-yaml uses DEFAULT_SCHEMA by default and implicitly casts date-like strings to Date (timestamp). This caused unexpected config values where dates were supposed to remain plain strings.

Switched YAML parsing to JSON_SCHEMA to disable timestamp resolution and keep date-like values as strings.
2026-02-17 18:38:40 +05:30
Pooja
d8367e28ad fix: sse response download button (#7081)
Co-authored-by: Sid <siddharth@usebruno.com>
2026-02-17 17:34:36 +05:30
naman-bruno
5021226360 fix: update protobuf and import path handling in opencollection (#7166) 2026-02-17 15:15:32 +05:30
Bijin A B
dfc3a1b78c chore: fix flaky playwright tests (#7159) 2026-02-17 01:25:41 +05:30
Simon AUBERT
634b62642f feat: add some doc about variable usage (#6443)
* add some doc about variable usage

* add some doc about variable usage

* add some doc about variable usage
2026-02-16 18:24:18 +05:30
Steven
8724201148 Feature: Object Variable Interpolation (#6317)
* Added JSON stringify for object values to allow for proper display
Added styling changes to accommodate objects

* Switching to a multi line approach
2026-02-16 18:19:40 +05:30
Chae Jeong Ah
f7cedcbd92 fix: incorrect response formatting when saving examples (#6528) 2026-02-16 18:15:19 +05:30
Simon Pauget
22ff82f57a fix: importing from openapi excludes documentation for requests (#6439)
* fix: importing from openapi excludes documentation for requests

fixes issue 5094 for requests

* fix: revert accidental changes

---------

Co-authored-by: Simon PAUGET <simon.pauget@depinfonancy.net>
Co-authored-by: Simon PAUGET <simon.pauget@decathlon.com>
2026-02-16 18:11:17 +05:30
SahilShameerDev
f766ec2239 fix: improve visual hierarchy of markdown headers in docs (#7145) 2026-02-16 18:06:40 +05:30
shubh-bruno
9e939a2188 feat: remove headers from request using scripts (#7122) 2026-02-16 12:07:35 +05:30
Bijin A B
471333fb80 chore: fix flaky tests (#7144) 2026-02-14 21:03:58 +05:30
Bijin A B
1d126dcb65 fix: flaky tests - standardize save keyboard shortcut across tests (#7141) 2026-02-14 03:58:02 +05:30
Bijin A B
0c3b828b09 fix: update header validation test to use triple-click for selecting all text (#7140) 2026-02-14 01:40:51 +05:30
Abhishek S Lal
e000e377d1 feat: move import collection from git url and spec url from enterprise edition to opensource (#7127)
* feat: move import collection from git url and spec url from enterprise edition to opensource

* fix: corrected a typo

* test: add unit and e2e tests for import collection migration

* fix: guard against missing userAgentData platform in getOSName — Default platform to '' to prevent TypeError when navigator.userAgentData is unavailable (GitNotFoundModal/index.js)

fix: UID mismatch between status tracking and UI rendering in bulk import — Preserve synthetic file-${index} UID on converted collections so initialStatus, rename tracking, and the render loop all use the same key (BulkImportCollectionLocation/index.js)

fix: isConfirmDisabled returning non-boolean value — Changed .length checks to explicit comparisons (> 0, === 0) so the function always returns true/false (CloneGitRespository/index.js)

fix: missing ipcRenderer declaration in cloneGitRepository and scanForBrunoFiles — Added const { ipcRenderer } = window; to both actions to prevent ReferenceError at runtime (collections/actions.js)

fix: use strict equality in filterItemsInCollection — Changed == to === for item.name and item.type comparisons (importers/common.js)

fix: variable shadowing in transformItemsInCollection and hydrateSeqInCollection — Renamed forEach callback parameter from collection to col to avoid shadowing the outer parameter (importers/common.js)

fix: scanForBrunoFiles traversing node_modules and .git directories — Added exclusion for node_modules and .git to match getCollectionStats pattern, preventing app freezes on large repos (filesystem.js)

fix: diff hunk header using string character count instead of line count — Preserved prefixedLines array to compute lineCount before joining, so the @@ header has the correct line count (git.js)

fix: test locators not scoped to modal in bulk import e2e test — Changed page.getByTestId to bulkImportModal.getByTestId for grouping dropdown interactions (002-all-collection-types.spec.ts)

fix: missing afterEach cleanup in GitHub repository import test — Added closeAllCollections hook to match sibling test specs, replaced unused dotenv/config import (github-repository-import.spec.ts)

* fix: batch name tracking and git utility fixes

- Fix usedNamesInBatch tracking original name instead of final name, which
  could produce duplicate environment names within the same batch
  (BulkImportCollectionLocation/index.js)

- Remove unused lodash import (git.js)

- Add missing early return in fetchRemotes when gitRootPath is falsy,
  preventing getSimpleGitInstanceForPath from running with undefined (git.js)

* fix: correct variable naming and state management in CloneGitRepository component

- Renamed `collectionpaths` to `collectionPaths` for consistency and clarity.
- Updated references throughout the component to use the corrected variable name.
- Removed error toast notification to streamline error handling during repository cloning.
2026-02-13 19:35:23 +05:30
Sid
4e1123bd2d tests: fix breaking tests (#7132)
* fix: update placeholder text for environment variable input

* fix: handle undefined color in environment objects

Don't export if `undefined`

* fix: update collection import logic for YML and BRU formats

* fix: ensure error icon is not visible after header validation

* fix: specify format for collection and environment serialization
2026-02-13 19:08:02 +05:30
Pooja
ac33c909ef fix: env draft loss on color change and rename (#7130) 2026-02-13 16:14:20 +05:30
Chirag Chandrashekhar
53e158c6d1 Feature/scratch requests (#7087)
* feat: implement workspace-level scratch requests

Add support for temporary "scratch" requests at the workspace level that
are not tied to any collection. These requests are stored in a temp
directory and displayed as tabs in the workspace home.

Key changes:
- Add IPC handlers for mounting scratch directory and creating requests
- Add scratch directory watcher in collection-watcher.js
- Extend workspaces Redux slice with scratch state and reducers
- Add IPC listeners for scratch request events
- Create ScratchRequestPane and CreateScratchRequest components
- Update WorkspaceTabs with "+" button for creating scratch requests
- Update WorkspaceHome to render scratch request tabs
- Filter scratch collections from sidebar display

Supports all request types: HTTP, GraphQL, gRPC, and WebSocket.

* style: improve create scratch request button styling

- Use Button component with ghost variant and primary color
- Position button inside scrollable tab area
- Vertically center button with tabs
- Clean up unnecessary CSS properties

* fix: append scratch request dropdown to body to fix z-index issue

* refactor: improve scratch collection detection with path registration

- Add centralized scratch path tracking in backend (scratchCollectionPaths Set)
- Register scratch paths when created via renderer:mount-workspace-scratch
- Set brunoConfig.type='scratch' based on registered paths instead of string pattern
- Store scratchTempDirectory path in workspace state for frontend validation
- Update schema to accept 'scratch' as valid collection type
- Simplify frontend filtering to use brunoConfig.type or path comparison
- Remove fragile 'bruno-scratch-' string pattern matching
- Prevent scratch collections from being added to workspace.collections

* refactor: use CreateTransientRequest for scratch requests in workspace tabs

- Remove CreateScratchRequest component in favor of reusing CreateTransientRequest
- Register scratch collection temp directory via addTransientDirectory for transient request creation
- Add scratch collection item sync with workspace tabs
- Display HTTP method with color on scratch request tabs

* refactor: unify WorkspaceTabs with RequestTabs system

Remove separate WorkspaceTabs system and integrate workspace views (Overview, Environments) into the unified RequestTabs architecture using scratch collections.

Key changes:
- Remove WorkspaceTabs component and Redux slice
- Add workspaceOverview and workspaceEnvironments as special tab types
- Create WorkspaceHeader component to display workspace name in toolbar
- Make workspace tabs non-closable and always present
- Update tab creation on workspace switch to automatically add Overview and Environments tabs
- Simplify WorkspaceHome component by removing redundant header
- Update all references from WorkspaceTabs to unified tab system

Benefits:
- Single tab system for all content (collections and workspace views)
- Consistent UX with unified navigation pattern
- Reduced code complexity (~1000+ lines removed)
- Easier maintenance and feature development

* fix: enable automatic tab creation for scratch collection transient requests

- Add updateCollectionMountStatus to properly set scratch collection mount status to 'mounted'
- Create new renderer:add-collection-watcher IPC handler for explicit watcher setup
- Move workspace tab type checks before collection validation in RequestTabPanel
- Update mountScratchCollection to use explicit watcher setup instead of open-multiple-collections

This ensures the task middleware recognizes scratch collections as fully mounted,
allowing transient requests to automatically open in tabs when created.

* feat: add collection selector with breadcrumb navigation for scratch requests

Add multi-step save flow for scratch collection requests with collection selection before folder selection. Includes breadcrumb navigation showing "Collections > [Selected Collection] > [Folders...]" that allows users to navigate back to collection selector.

Refactor scratch collection detection to use workspace.scratchCollectionUid instead of persisting type to brunoConfig, providing cleaner separation of concerns without disk persistence.

Add backend support for automatic format conversion when saving from YAML scratch collections to BRU collections.

* chore: remove redundant comments and simplify code

* fix: use focusTab for home button, remove unused ScratchRequestPane

* fix: improve SaveTransientRequest collection mounting and selection flow

* refactor: use WorkspaceOverview directly, remove WorkspaceHome wrapper

* feat: add workspace management dropdown with rename, export, and close options

* refactor: extract CollectionListItem component with Redux selector for mount status

* refactor: separate scratch collection handling from openCollectionEvent

- Create dedicated openScratchCollectionEvent function for scratch collections
- Revert openCollectionEvent to clean state without scratch-specific logic
- Simplify closeTabs and closeAllCollectionTabs reducers in tabs slice
- Remove unused isScratchCollectionPath helper function

* test: add scratch requests test suite

- Add tests for creating scratch requests (HTTP, GraphQL, gRPC, WebSocket)
- Add tests for sending scratch requests and verifying response
- Add tests for saving scratch requests to a collection
- Add tests for multiple tabs and closing tabs
- Handle "Don't Save" modal in playwright fixture during app close

* refactor: address code review feedback for scratch requests feature

- Fix RequestTabPanel hooks violation by moving SSR guard after hooks
- Fix validateWorkspaceName to trim before length check
- Use stable deterministic UID in SaveTransientRequest
- Use ES6 shorthand for collectionUid in useIpcEvents
- Add JSDoc and error handling to openScratchCollectionEvent
- Fix closeAllCollectionTabs to preserve activeTabUid when not removed
- Add syncExampleUidsCache call to save-scratch-request handler
- Use getCollectionFormat for save-transient-request extension handling
- Fix Playwright modal handling with proper waitFor pattern
- Make keyboard shortcut platform-aware in scratch tests
- Remove flaky close tab test, handled by fixture cleanup
- Extract isScratchCollection utility to reduce duplication
- Memoize scratch collection derivation in RequestTabs
- Use theme color instead of Tailwind class for success icon
- Wrap resetForm and handleCancelWorkspaceRename in useCallback
- Extract FolderBreadcrumbs into separate component
- Call reset() inside useEffect in useCollectionFolderTree hook
- Defer workspace scratch state updates until mount succeeds

* feat: add unified collection header with context switcher dropdown

- Create CollectionHeader component that replaces WorkspaceHeader and CollectionToolBar
- Add dropdown to switch between workspace and mounted collections
- Display tab count badge next to collection/workspace name in header and dropdown
- Remove unused WorkspaceHeader and CollectionToolBar components
- Handle null/undefined values elegantly throughout

* chore: allow pr to comment

* refactor: improve scratch requests test cleanup with closeAllTabs helper

- Revert playwright/index.ts modal handling hack
- Add closeAllTabs helper to test utils for proper tab cleanup
- Update scratch-requests test to use closeAllTabs in afterAll
- Fix test assertion for new collection header dropdown structure

---------
Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-02-13 15:34:47 +05:30
shubh-bruno
3e581675cd fix: cURL import NDJSON in request body as text (#7002)
* fix: cURL import NDJSON request body as text

* fix: cURL import NDJSON request body as text

* fix: cURL import NDJSON request body as text

* fix: resolved comments for body.text

* fix: add NDJSON content type detection

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-02-12 21:10:04 +05:30
sanish chirayath
e03cf9a519 Feat/support missing env apis (#7069)
* feat: add support for new variable management functions in Bruno

- Implemented methods to retrieve and delete all environment and global variables.
- Added corresponding translations for new functions in Postman and Bruno converters.
- Updated request handling to include header deletion functionality.
- Enhanced test cases to cover new variable management features.

* feat: add new scripts for environment and global variable management

- Introduced scripts to delete all environment and global variables.
- Added functionality to retrieve all environment and global variables.
- Implemented tests to validate the behavior of new variable management features.

* feat: implement collection variable management in Bruno

- Added methods for managing collection variables: set, get, has, delete, and retrieve all.
- Updated Postman translation functions to reflect new collection variable methods.
- Enhanced tests to validate the functionality of collection variable management.
- Refactored existing code to replace environment variable references with collection variable equivalents.

* feat: enhance collection variable translations in Bruno

- Updated translation functions for collection variable management to align with Postman API.
- Added tests for new collection variable methods: set, has, delete, retrieve all, and clear.
- Refactored existing tests to ensure accurate translation of collection variable operations.

* feat: expand API hints for variable management in Bruno

* fix: test cases

* fix: remove unnecessary return in deleteEnvVar function
2026-02-12 18:38:25 +05:30
Pragadesh-45
91467f699c feat: enhance axios shim error handling and add comprehensive tests (#6349) 2026-02-12 17:37:48 +05:30
sanish chirayath
3871ca9edd feat: enhance translation capabilities for Bruno to Postman conversion (#7052)
* feat: enhance translation capabilities for Bruno to Postman conversion

- Added support for translating req.getHost(), req.getPath(), and req.getQueryString() to their Postman equivalents.
- Implemented translation for req.getPathParams() to pm.request.url.variables.
- Introduced handling for bru.visualize() to pm.visualizer.set() with various argument types.
- Added tests to validate new translation features and ensure correct behavior for URL-related methods and visualizer functionality.

* rm: duplicates

* refactor: remove bru.visualize transformation and associated tests

* feat: enhance BDD-style assertion translations in Postman converter

- Updated transformation logic to translate BDD-style assertions like pm.response.to.be.ok, pm.response.to.be.success, and others to their corresponding expect statements.
- Added comprehensive tests to validate the new translations for various response status checks.
- Improved handling of BDD assertions within test blocks to ensure accurate translation.

* fix: correct variable naming in transformation logic for Postman converter

- Updated variable names in the transformation logic to improve clarity and consistency.
- Ensured that the correct nodes are replaced and added to the transformedNodes set during processing.

* fix: improve AST mutation handling in Postman to Bruno translation

- Enhanced the processTransformations function to capture stable references before mutating the AST, ensuring correct node replacement and insertion.
- Added a defensive guard for ExpressionStatements to prevent errors when accessing undefined properties.
- Improved the logic for inserting remaining nodes after the grandparent in reverse order to maintain the correct sequence.

* fix: remove unnecessary defensive guard in AST mutation for Postman to Bruno translation
2026-02-12 17:17:39 +05:30
sanish chirayath
2517fe078f refactor: enhance gRPC methods loading with cache indication (#7022)
* refactor: enhance gRPC methods loading with cache indication

- Updated `loadMethodsFromReflection` and `loadMethodsFromProtoFile` to return a `fromCache` flag indicating whether methods were loaded from cache.
- Adjusted success toast messages to only display when methods are not loaded from cache, improving user feedback on data retrieval.

* empty commit

* empty
2026-02-12 17:13:54 +05:30
Chirag Chandrashekhar
7f047a4412 fix: multipart form-data file param export/import for Postman (#7111) 2026-02-12 16:41:25 +05:30
sanish chirayath
d30ab4d984 feat: add translations for direct cookie access methods (#7070)
* feat: add translations for direct cookie access methods

- Implement translations for pm.cookies.has, pm.cookies.get, and pm.cookies.toObject to their corresponding bru.cookies methods.
- Enhance the postman-to-bruno translator to handle these new cookie access patterns.
- Add unit tests to verify the correct conversion of cookie access methods in various scenarios.

* refactor: simplify optional member expression handling in postman-to-bruno translator

- Streamlined the code for handling optional member expressions in the translation of cookie access methods.
- Updated unit test to verify the correct output format for pm.cookies.toObject() conversion.

* refactor: enhance handling of await expressions in cookie translations

- Updated the postman-to-bruno translator to wrap await expressions in parentheses for improved clarity and consistency.
- Adjusted unit tests to reflect the new output format for cookie access methods, ensuring accurate translation of pm.cookies.get calls.

* refactor: update cookie access translations to use hasCookie method

- Modified translations for pm.cookies.has to utilize the new bru.cookies.hasCookie method for improved clarity and functionality.
- Updated related unit tests to reflect changes in expected output for cookie existence checks.
- Added new tests to validate the behavior of the hasCookie method in various scenarios.
2026-02-12 14:30:35 +05:30
Sanjai Kumar
836c2b9ace fix: graphQL variables interpolation consistency (UI and CLI) (#7049)
* feat: enhance GraphQL request handling with variable interpolation
2026-02-12 13:48:35 +05:30
Sanjai Kumar
e1827080dd chore: update swagger-ui-react (#7086) 2026-02-12 12:09:12 +05:30
lohit
ff87eb23ee fix(node-vm): scripting context and module resolution (#7033)
* fix(node-vm): scripting context and module resolution issues

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): use vm.createContext for true isolation and fix prototype mismatches

- Replace vm.compileFunction with vm.createContext + runInContext for true isolation
- Remove ECMAScript built-ins from safeGlobals (VM provides its own versions)
- This fixes prototype chain mismatches that broke libraries like @faker-js/faker
- Add sanitized process object (allows env, blocks exit/kill)
- Add global/globalThis pointing to isolated context (not host)
- Extract safe globals to constants.js for maintainability
- Remove typed-arrays mixin (VM provides TypedArrays)
- Add comprehensive isolation tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): remove process, add Error types and TypedArrays mixin, add jose test

- Remove process object from script context (security hardening)
- Remove createSanitizedProcess function from constants.js
- Add Error types to safeGlobals for instanceof checks with host errors
- Add TypedArrays mixin for host API compatibility (TextEncoder, crypto, Buffer)
- Add jose library and test for JWT sign/verify functionality
- Update tests to reflect process removal

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): handle circular dependencies and failed module caching

- Pre-populate module cache before execution to support circular requires
- Cache moduleObj instead of moduleObj.exports to handle module.exports reassignment
- Remove failed modules from cache to allow retry
- Add test for circular dependency handling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): spread all context properties in buildScriptContext

Instead of explicitly listing each context property, spread all
properties from the context input to support future additions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): add filtered process object to script context

Expose a sanitized process object with only safe read-only properties
(argv, version, arch, platform, pid, features) while keeping env empty
for security.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(node-vm): add comprehensive tests for Node.js builtins

Add 18 test files for Node.js builtin APIs in developer sandbox mode:
- Buffer, URL, TextEncoder/TextDecoder, btoa/atob
- Web Crypto API and node:crypto module
- Timers (setTimeout, setInterval, setImmediate, queueMicrotask)
- Fetch API (Request, Response, Headers, FormData, Blob)
- Intl formatters, JSON, Events (Event, EventTarget, CustomEvent)
- Node modules: fs, path, os, util, stream, zlib, querystring

All tests skip in safe mode using bru.runner.skipRequest().

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(node-vm): address CodeRabbit review feedback

- Block absolute paths from bypassing security by routing through loadLocalModule
- Fix process tests to expect sanitized object instead of undefined
- Fix cache test to verify module executes only once
- Add tests for absolute path handling (block outside, allow within roots)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: lint issues

* fix(node-vm): recontextualize host objects for cross-context deep equality

Objects passed from the host context into the Node VM have different
Object/Array constructors than objects created inside the VM. This breaks
deep equality checks in libraries like AJV, where fast-deep-equal fails
on `a.constructor !== b.constructor` for structurally identical objects.

Add recontextualizeScript to utils.js that wraps getter methods (res.getBody,
res.getHeaders, req.getBody, req.getHeaders, req.getPathParams, req.getTags,
bru.getVar) to JSON round-trip returned objects inside the VM, giving them
VM-native prototypes.

Add external-lib-with-bru-req-res-objects package and tests to verify
bru/req/res accessibility from npm modules. Update ajv.bru tests to
validate res.getBody() against AJV schemas with enum on nested objects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(node-vm): update spec to use saved mock refs after recontextualize

The recontextualizeScript wraps res.getBody with a JSON round-trip
function, replacing the jest mock on the context object. Save mock
references before calling runScriptInNodeVm so assertions work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(node-vm): shallow-copy mutable process properties in sandbox

process.argv, process.versions, and process.features were passed by
reference, allowing sandboxed scripts to mutate the host process.
Shallow-copy these properties to prevent leaking mutable references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(node-vm): use recursive clone in toVMNative instead of JSON round-trip

JSON.stringify converts undefined to null in arrays, breaking tests like
res.setBody([..., undefined, ...]). Replace with recursive clone that
creates new VM-native objects/arrays while preserving undefined values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(node-vm): generalize recontextualize to wrap all bru/req/res methods

Instead of hardcoding specific method names, walk the prototype chain
with Object.getOwnPropertyNames to discover and wrap all methods that
return Objects/Arrays. Async methods (sendRequest, runRequest) get their
resolved values wrapped. The res callable and res.body/res.headers are
also recontextualized for direct access and query usage.

Adds integration tests for VM-native prototype checks across res, req,
bru APIs, res() callable queries, and bru.sendRequest patterns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* revert(node-vm): remove recontextualizeScript and related tests

The recontextualize approach of wrapping all bru/req/res methods
to return VM-native objects is being reverted in favor of a
different solution to the cross-context prototype mismatch issue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(node-vm): expose full process object in developer sandbox via safeGlobals

* test(node-vm): update process tests for full process object in developer sandbox

* test(node-vm): update spec to verify process.nextTick availability

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 01:03:35 +05:30
Abhishek S Lal
7460078fd6 fix: enhance tag handling and validation in collection import/export (#7107)
- Added collection format handling in Tags component.
- Updated convertCollection function to accept collectionFormat parameter.
- Improved tag validation logic in TagList component based on collection format.
- Adjusted OpenAPI transformation functions to support collection format options.
- Enhanced schema validation for tags to allow spaces and underscores.
2026-02-12 00:42:16 +05:30
Bijin A B
e4b6f7a28b fix(save-all): fix save all modified requests while closing the app (#7118) 2026-02-12 00:07:23 +05:30
Abhishek S Lal
bac51191ee fix: enhance HTTP response status validation in stringifyHttpRequest function (#7117)
Updated the response status handling to ensure it is a positive integer before assignment, improving data integrity in HTTP request stringification.
2026-02-11 21:16:41 +05:30
Chirag Chandrashekhar
6f4489a8f3 Fix/save transient request new folder theme match (#7116)
* fix: match filesystem name input style to NewFolder modal in SaveTransientRequest

- Update label to match NewFolder format with '(on filesystem)' suffix
- Add folder icon before the input field
- Apply PathDisplay-like styling with yellow text color and monospace font
- Use matching background, border, and padding from PathDisplay component

* fix: add edit toggle and help tooltip to SaveTransientRequest filesystem name

- Add edit/display mode toggle matching NewFolder modal behavior
- Show PathDisplay when not editing, input field when editing
- Add Help tooltip with placement support for filesystem name field
- Add placement prop to Help component (top, bottom, left, right)
- Remove unused filesystem input styles from StyledWrapper

* fix: update Help component usage in SaveTransientRequest filesystem name field

- Change Help component width prop from a string to a number for consistency.
2026-02-11 21:15:25 +05:30
naman-bruno
2d8c767b90 fix: collection zip import for default workspace (#7108)
* fix: collection zip import for default workspace

* fixes
2026-02-11 19:12:08 +05:30
lohit
ccac391848 fix: pass app-level proxy config to bru.sendRequest (#7113)
When collection proxy is set to "inherit", bru.sendRequest was skipping
the app-level proxy and falling through directly to system proxy. Now it
correctly checks app-level proxy settings first, matching the behavior
of normal requests. When appLevelProxyConfig is not provided (e.g. CLI),
falls through to system proxy preserving existing behavior.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:08:37 +05:30
gopu-bruno
bff4da336a fix: update codemirror bg for pastel light theme (#7110) 2026-02-11 18:09:53 +05:30
Chirag Chandrashekhar
4c779da2d3 fix: match filesystem name input style to NewFolder modal in SaveTransientRequest (#7109)
- Update label to match NewFolder format with '(on filesystem)' suffix
- Add folder icon before the input field
- Apply PathDisplay-like styling with yellow text color and monospace font
- Use matching background, border, and padding from PathDisplay component
2026-02-11 18:07:46 +05:30
Pooja
5d0a15121c fix: persist environment color on import/export (#7045) 2026-02-11 16:20:39 +05:30
naman-bruno
215c9f9e8a fix: filter existing paths for apispec in workspace (#7104) 2026-02-11 15:02:31 +05:30
Sid
828cb19048 fix: improve environment variable comparison by stripping UIDs (#7100) 2026-02-11 12:39:04 +05:30
Pooja
a86f0e492f fix: env color picker ui (#7096)
* fix: env color picker ui

* rm: toast for color change

* fix: color slider alignment
2026-02-11 12:38:11 +05:30
Sid
7d25d13436 fix: improve value handling in editor components (#7098) 2026-02-10 19:08:36 +05:30
lohit
00a59840fb fix: mark Node.js built-in modules as external in rollup config (#7095)
Use `isBuiltin` from the `module` package to dynamically exclude all
Node.js built-in modules from the bundle, preventing rollup from
trying to bundle core modules like path, fs, crypto, etc.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 18:25:38 +05:30
naman-bruno
ffa3509e8e handle unsaved changes in dot env file editor (#7094)
* handle unsaved changes in dot env file editor

* fixes
2026-02-10 17:32:54 +05:30
Chirag Chandrashekhar
82d93ec840 fix: validate folder and file names in SaveTransientRequest component (#7060)
- Added validation for folder and file names to ensure they are not empty and conform to naming rules.
- Display error messages using toast notifications for invalid names.
2026-02-10 17:21:51 +05:30
Pooja
9127be8498 fix: openapi content level example (#7091)
* fix: openapi content level example

* add: unit tests
2026-02-10 15:56:42 +05:30
gopu-bruno
1d1c3d83ec fix: disable text-overflow ellipsis on checkbox column (#7080) 2026-02-09 19:33:28 +05:30
naman-bruno
aa2d7a120f feat: validate ZIP file format for collections before import (#7085) 2026-02-09 18:15:44 +05:30
Pooja
20eb7b7277 fix: header and var tooltip overflow (#7082) 2026-02-09 18:04:10 +05:30
naman-bruno
37fbdec983 feat: add ZIP file import for collections (#7063)
* feat: add ZIP file import for collections
2026-02-09 15:00:54 +05:30
Chirag Chandrashekhar
3b0370643a feat: implement filtering of transient items across collection operations (#7062)
- Added `filterTransientItems` utility to recursively remove transient items from collections.
- Updated export functions for OpenCollection and Postman to filter out transient items before export.
- Enhanced collection handling in various components to skip transient requests during processing.
- Adjusted RunConfigurationPanel to exclude transient items from request handling.
2026-02-09 11:48:50 +05:30
Bijin A B
e3bf8f29b8 Merge pull request #5189 from fantpmas/feature/autocomplete-substring
Make autocomplete work with substrings
2026-02-06 21:20:12 +05:30
Bijin A B
edee75e372 feat(autocomplete): minor refactor and add unit tests 2026-02-06 20:47:19 +05:30
naman-bruno
786326ae80 Merge pull request #7067 from naman-bruno/fix/import-tests
fix: import tests
2026-02-06 20:05:17 +05:30
Chirag Chandrashekhar
814663acb9 feat: enhance SaveTransientRequest component with folder navigation and input handling improvements (#7061) 2026-02-06 18:07:08 +05:30
Chirag Chandrashekhar
1c5e1c5fcf bugfix: auto open saved transient request (#7058) 2026-02-06 17:57:54 +05:30
Thomas Vackier
3c0d9ccd4c feat: make autocomplete work with substrings 2026-02-06 16:57:07 +05:30
Chirag Chandrashekhar
f07c93d613 fix: update dependency in CreateTransientRequest to include collectionUid in useMemo dependencies (#7057) 2026-02-06 13:02:31 +05:30
Chirag Chandrashekhar
319422c20f fix: improve error handling in CreateTransientRequest and SaveTransientRequest components (#7059) 2026-02-06 12:40:07 +05:30
Chirag Chandrashekhar
78240d9232 Bugfix/close saved deleting collections (#7048) 2026-02-06 12:31:58 +05:30
Pooja
1443fb0f4e fix: code editor null value crash (#7039) 2026-02-05 16:33:35 +05:30
Bijin A B
e6dd582a02 Merge pull request #6043 from james-ha-bruno/feature/set-map-support-for-logging
Feature/set map support for logging
2026-02-05 15:54:17 +05:30
Bijin A B
29e5ab95fe feat(console): minor refactor and extend set and map logging support into developer mode 2026-02-04 22:15:28 +05:30
James Ha
79ce71c040 feat: improve Map and Set logging display in console
- Remove size property from Map and Set displays
- Display Set values at top level with numeric indices (0, 1, 2, ...)
- Display Map entries at top level with => notation (key =>: value)
- Remove [[Set]] and [[Map]] wrapper properties for cleaner display
- Collapse Maps and Sets by default in console (matching Postman behavior)
- Add 'Map' and 'Set' type labels to clearly identify object types
- Maintain expandable/collapsible UI for easy inspection of contents
2026-02-04 17:32:01 +05:30
James Ha
15c2373fb0 add first attempt of adding set / map logic 2026-02-04 17:32:01 +05:30
Bijin A B
27da99b817 Fix/runner results enhancement (#7040)
* Mark test script errors as failed in runner (#6261)

* Mark test script errors as failed in runner
    and CLI

* Unify handling of post-response and pre-request script errors in both CLI and Electron

* feat: Enhance error handling in script execution by preserving partial results for pre-request and post-response scripts across CLI and Electron. This ensures that tests passing before an error are still reported.

* Preserving stopExecution in test script error handler

---------

Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>

* Enhance error handling for script execution by introducing isScriptError flag in test results (#7029)

* fix: Enhance error handling for script execution by introducing isScriptError flag in test results

Enhance error reporting in script execution by adding isScriptError flag to error responses

fix: Mark pre-request script errors as failures in runner summary

---------

Co-authored-by: Karan Pradhan <78605930+KaranPradhan266@users.noreply.github.com>
Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
2026-02-04 16:26:31 +05:30
Pragadesh-45
ce01c69395 feat: Red status indicator for script errors in Request, Collection, and Folder Script tabs (#7035) 2026-02-04 15:54:43 +05:30
Pooja
cdc3cb3bdf fix: preserve empty query param equal sign (#7031) 2026-02-04 15:43:21 +05:30
Pooja
4de470525d fix: add missing URL helper translations for Bruno to Postman export (#7026)
* fix: add missing URL helper translations for Bruno to Postman export

* fix : comment
2026-02-04 15:16:04 +05:30
Sanjai Kumar
798db041fa Enhance error handling for script execution by introducing isScriptError flag in test results (#7029)
* fix: Enhance error handling for script execution by introducing isScriptError flag in test results

Enhance error reporting in script execution by adding isScriptError flag to error responses

fix: Mark pre-request script errors as failures in runner summary
2026-02-04 14:59:32 +05:30
Karan Pradhan
5672745b76 Mark test script errors as failed in runner (#6261)
* Mark test script errors as failed in runner
    and CLI

* Unify handling of post-response and pre-request script errors in both CLI and Electron

* feat: Enhance error handling in script execution by preserving partial results for pre-request and post-response scripts across CLI and Electron. This ensures that tests passing before an error are still reported.

* Preserving stopExecution in test script error handler

---------

Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
2026-02-04 13:12:10 +05:30
578 changed files with 32540 additions and 5923 deletions

View File

@@ -23,6 +23,19 @@ reviews:
drafts: false
base_branches: ['main', 'release/*']
path_instructions:
- path: '**/*'
instructions: |
Bruno is a cross-platform Electron desktop app that runs on macOS, Windows, and Linux. Ensure that all code is OS-agnostic:
- File paths must use `path.join()` or `path.resolve()` instead of hardcoded `/` or `\\` separators
- Never assume case-sensitive or case-insensitive filesystems
- Use `os.homedir()`, `app.getPath()`, or environment-appropriate APIs instead of hardcoded paths like `/home/`, `C:\\Users\\`, or `~/`
- Line endings should be handled consistently (be aware of CRLF vs LF issues)
- Use `path.sep` or `path.posix`/`path.win32` when platform-specific separators are needed
- Shell commands or child_process calls must account for platform differences (e.g., `which` vs `where`, `/bin/sh` vs `cmd.exe`)
- File permissions (e.g., `fs.chmod`, `fs.access`) should account for Windows not supporting Unix-style permission bits
- Avoid relying on Unix-only signals (e.g., `SIGKILL`) without Windows fallbacks
- Use `os.tmpdir()` instead of hardcoding `/tmp`
- Environment variable access should handle platform differences (e.g., `HOME` vs `USERPROFILE`)
- path: 'tests/**/**.*'
instructions: |
Review the following e2e test code written using the Playwright test library. Ensure that:

View File

@@ -1,5 +1,10 @@
name: 'Setup Node Dependencies'
description: 'Install Node.js and npm dependencies'
inputs:
skip-build:
description: 'Skip building libraries'
required: false
default: 'false'
runs:
using: 'composite'
steps:
@@ -9,12 +14,13 @@ runs:
node-version: v22.17.0
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install node dependencies
shell: bash
run: npm ci --legacy-peer-deps
- name: Build libraries
if: inputs.skip-build != 'true'
shell: bash
run: |
npm run build:graphql-docs

View File

@@ -0,0 +1,20 @@
name: 'Run CLI Tests'
description: 'Setup dependencies, start local testbench and run CLI tests'
runs:
using: 'composite'
steps:
- name: Run Local Testbench
shell: bash
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Install Test Collection Dependencies
shell: bash
run: npm ci --prefix packages/bruno-tests/collection
- name: Run CLI Tests
shell: bash
run: |
cd packages/bruno-tests/collection
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer

View File

@@ -0,0 +1,22 @@
name: 'Run E2E Tests'
description: 'Setup dependencies, configure environment, and run Playwright E2E tests'
inputs:
os:
description: 'Operating system (ubuntu, macos, windows)'
default: 'ubuntu'
runs:
using: 'composite'
steps:
- name: Install Test Collection Dependencies
shell: bash
run: npm ci --prefix packages/bruno-tests/collection
- name: Run Playwright Tests (Ubuntu)
if: inputs.os == 'ubuntu'
shell: bash
run: xvfb-run npm run test:e2e
- name: Run Playwright Tests
if: inputs.os != 'ubuntu'
shell: bash
run: npm run test:e2e

View File

@@ -0,0 +1,48 @@
name: 'Run Unit Tests'
description: 'Setup dependencies and run unit tests for all packages'
runs:
using: 'composite'
steps:
- name: Test Package bruno-js
shell: bash
run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-cli
shell: bash
run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-query
shell: bash
run: npm run test --workspace=packages/bruno-query
- name: Test Package bruno-lang
shell: bash
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema
shell: bash
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
shell: bash
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-common
shell: bash
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-converters
shell: bash
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
shell: bash
run: npm run test --workspace=packages/bruno-electron
- name: Test Package bruno-requests
shell: bash
run: npm run test --workspace=packages/bruno-requests
- name: Test Package bruno-filestore
shell: bash
run: npm run test --workspace=packages/bruno-filestore

View File

@@ -9,6 +9,7 @@ on:
permissions:
contents: read
pull-requests: write
issues: write
checks: write
jobs:

26
.github/workflows/lint-checks.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Lint Checks
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
jobs:
lint:
name: Lint Check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
with:
skip-build: 'true'
- name: Lint Check
run: npm run lint
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}

View File

@@ -1,9 +1,10 @@
name: Tests
on:
workflow_dispatch:
push:
branches: [main]
branches: [main, 'release/v*']
pull_request:
branches: [main]
branches: [main, 'release/v*']
jobs:
unit-test:
@@ -14,52 +15,12 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install dependencies
run: npm ci --legacy-peer-deps
# build libraries
- name: Build libraries
run: |
npm run build --workspace=packages/bruno-common
npm run build --workspace=packages/bruno-query
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-schema-types
npm run build --workspace=packages/bruno-filestore
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Lint Check
run: npm run lint
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
# tests
- name: Test Package bruno-js
run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-cli
run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-query
run: npm run test --workspace=packages/bruno-query
- name: Test Package bruno-lang
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-common
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-converters
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron
- name: Test Package bruno-requests
run: npm run test --workspace=packages/bruno-requests
- name: Run Unit Tests
uses: ./.github/actions/tests/run-unit-tests
cli-test:
name: CLI Tests
@@ -70,35 +31,12 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Build Libraries
run: |
npm run build --workspace=packages/bruno-query
npm run build --workspace=packages/bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-schema-types
npm run build --workspace=packages/bruno-filestore
- name: Run Local Testbench
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Run tests
run: |
cd packages/bruno-tests/collection
npm install
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
- name: Run CLI Tests
uses: ./.github/actions/tests/run-cli-tests
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action@v2
@@ -107,46 +45,38 @@ jobs:
check_name: CLI Test Results
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
e2e-test:
name: Playwright E2E Tests
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
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
- uses: actions/checkout@v6
- name: Install dependencies for test collection environment
run: |
npm ci --prefix packages/bruno-tests/collection
- name: Install System Dependencies (Ubuntu)
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
- name: 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
npm run build:schema-types
npm run build:bruno-filestore
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- name: Configure Chrome Sandbox
run: |
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run playwright Tests
uses: ./.github/actions/tests/run-e2e-tests
with:
os: ubuntu
- name: Upload Playwright Report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

1
.gitignore vendored
View File

@@ -49,6 +49,7 @@ bruno.iml
.idea
.vscode
.cursor
.claude
# Playwright
/blob-report/

1293
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.7.0",
"@opencollection/types": "~0.8.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -100,6 +100,7 @@
}
},
"dependencies": {
"ajv": "^8.17.1"
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
}
}

View File

@@ -1,7 +1,8 @@
module.exports = {
rootDir: '.',
transform: {
'^.+\\.[jt]sx?$': 'babel-jest'
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
},
transformIgnorePatterns: [
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'

View File

@@ -0,0 +1,8 @@
const babelJest = require('babel-jest')
module.exports = {
process(sourceText, sourcePath, options) {
const transformer = babelJest.createTransformer();
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
}
};

View File

@@ -88,7 +88,7 @@
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "5.17.12",
"swagger-ui-react": "^5.31.0",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
@@ -130,4 +130,4 @@
"form-data": "4.0.4"
}
}
}
}

View File

@@ -4,10 +4,12 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { createWorkspaceWithUniqueName, openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import get from 'lodash/get';
import Bruno from 'components/Bruno';
import MenuDropdown from 'ui/MenuDropdown';
@@ -129,7 +131,10 @@ const AppTitleBar = () => {
});
const handleHomeClick = () => {
dispatch(showHomePage());
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
if (scratchCollectionUid) {
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
}
};
const handleWorkspaceSwitch = (workspaceUid) => {
@@ -146,9 +151,20 @@ const AppTitleBar = () => {
}
};
const handleCreateWorkspace = () => {
setCreateWorkspaceModalOpen(true);
};
const handleCreateWorkspace = useCallback(async () => {
const defaultLocation = get(preferences, 'general.defaultLocation', '');
if (!defaultLocation) {
setCreateWorkspaceModalOpen(true);
return;
}
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}
}, [preferences, dispatch]);
const handleManageWorkspaces = () => {
dispatch(showManageWorkspacePage());
@@ -236,7 +252,7 @@ const AppTitleBar = () => {
);
return items;
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleCreateWorkspace]);
return (
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>

View File

@@ -16,6 +16,7 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
const CodeMirror = require('codemirror');
@@ -46,6 +47,9 @@ export default class CodeEditor extends React.Component {
this.state = {
searchBarVisible: false
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -217,6 +221,9 @@ export default class CodeEditor extends React.Component {
// Setup lint error tooltip on line number hover
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
// Setup keyboard shortcuts
this._shortcutsCleanup = setupShortcuts(editor, this);
}
}
@@ -233,10 +240,18 @@ export default class CodeEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
this.editor.setCursor(cursor);
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
}
if (this.editor) {
@@ -280,6 +295,12 @@ export default class CodeEditor extends React.Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this.editor);

View File

@@ -8,6 +8,44 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const MAX_MATCHES = 99_999;
function findSearchMatches(editor, searchText, regex, caseSensitive, wholeWord) {
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(searchText, caseSensitive ? 'g' : 'gi');
} catch (error) {
console.warn('Invalid regex provided in search!', error);
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(searchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = searchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
if (out.length >= MAX_MATCHES) {
break;
}
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}
function createCacheKey(editor, searchText, regex, caseSensitive, wholeWord) {
return `${editor.getValue().length}${searchText}${regex}${caseSensitive}${wholeWord}`;
}
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
@@ -19,49 +57,15 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const searchCacheKey = useRef('');
const inputRef = useRef(null);
const debouncedSearchText = useDebounce(searchText, 150);
const memoizedMatches = useMemo(() => {
if (!editor || !visible) return [];
if (!debouncedSearchText) return [];
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
} catch {
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(debouncedSearchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = debouncedSearchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
const debouncedSearchText = useDebounce(searchText, 250);
const doSearch = useCallback((newIndex = 0) => {
if (!editor) return;
if (!editor || !visible) {
return;
}
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Clear previous line highlight
if (searchLineHighlight.current !== null) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
@@ -71,41 +75,89 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
return;
}
try {
const matches = memoizedMatches;
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
const newCacheKey = createCacheKey(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
const isCacheHit = newCacheKey === searchCacheKey.current;
if (matches.length) {
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
} else {
searchLineHighlight.current = null;
let matches = searchMatches.current;
if (!isCacheHit) {
matches = findSearchMatches(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
searchMatches.current = matches;
searchCacheKey.current = newCacheKey;
setMatchCount(matches.length);
}
setMatchCount(matches.length);
if (!matches.length) {
setMatchIndex(0);
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
return;
}
const matchIndex = Math.max(0, Math.min(newIndex, matches.length - 1));
setMatchIndex(matchIndex);
searchMatches.current = matches;
if (isCacheHit) {
// Clear only old current mark
const oldIndex = searchMarks.current.findIndex((mark) => mark.className?.includes('cm-search-current'));
if (oldIndex !== -1) {
searchMarks.current[oldIndex].clear();
searchMarks.current.splice(oldIndex, 1);
}
// Add mark to the new current and remark the previous and next
const toMark = [
// Previous
matchIndex > 0 ? matchIndex - 1 : null,
// Current
matchIndex,
// Next
matchIndex < matches.length - 1 ? matchIndex + 1 : null
].filter((i) => i !== null);
toMark.forEach((i) => {
const mark = editor.markText(matches[i].from, matches[i].to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
} else {
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Mark all on new search
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
}
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
} catch (e) {
console.error('Search error:', e);
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchCacheKey.current = '';
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, visible]);
useImperativeHandle(ref, () => ({
focus: () => {
@@ -116,7 +168,7 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
}));
useEffect(() => {
doSearch(0, debouncedSearchText);
doSearch(0);
}, [debouncedSearchText, doSearch]);
const handleSearchBarClose = useCallback(() => {
@@ -127,6 +179,7 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
searchLineHighlight.current = null;
}
searchMatches.current = [];
searchCacheKey.current = '';
if (onClose) onClose();
// Focus the editor after closing the search bar
if (editor) {
@@ -142,32 +195,27 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const handleToggleRegex = () => {
setRegex((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleCase = () => {
setCaseSensitive((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleWholeWord = () => {
setWholeWord((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleNext = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
let next = (matchIndex + 1) % searchMatches.current.length;
setMatchIndex(next);
const next = (matchIndex + 1) % searchMatches.current.length;
doSearch(next);
};
const handlePrev = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
setMatchIndex(prev);
const prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
doSearch(prev);
};

View File

@@ -7,6 +7,7 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
@@ -73,6 +74,10 @@ const Script = ({ collection }) => {
dispatch(saveCollectionSettings(collection.uid));
};
const items = flattenItems(collection.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -83,11 +88,15 @@ const Script = ({ collection }) => {
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
</TabsList>

View File

@@ -1,21 +1,17 @@
import React from 'react';
import { useTheme } from 'providers/Theme';
const ColorBadge = ({ color, size = 10, showEmptyBorder = true }) => {
const ColorBadge = ({ color, size = 10 }) => {
const sizeValue = typeof size === 'string' ? size : `${size}px`;
const { theme } = useTheme();
const showBorder = !color && showEmptyBorder;
return (
<div
className="flex-shrink-0 rounded-full"
style={{
width: sizeValue,
height: sizeValue,
backgroundColor: color || 'transparent',
border: showBorder ? '1px solid' : 'none',
borderColor: showBorder ? theme.background.surface1 : 'transparent'
backgroundColor: color || 'transparent'
}}
/>
);

View File

@@ -134,15 +134,15 @@ const ColorPicker = ({ color, onChange, icon }) => {
))}
</div>
<div className="flex items-center gap-2 mt-2 pt-2">
<div className="flex items-center gap-2 mt-2 pt-0.5">
<div
className="w-4 h-4 rounded-full flex-shrink-0 cursor-pointer"
className="w-5 h-5 rounded-full flex-shrink-0 cursor-pointer"
style={{ backgroundColor: customColor }}
onClick={() => handleColorSelect(customColor)}
title="Custom color"
/>
<ColorRangePicker
className="flex-1"
className="flex-1 flex"
value={sliderPosition}
onChange={handleSliderChange}
onMouseUp={handleSliderEnd}

View File

@@ -4,6 +4,7 @@ const StyledWrapper = styled.div`
.hue-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
outline: none;

View File

@@ -2,14 +2,14 @@ import StyledWrapper from './StyledWrapper';
const ColorRangePicker = ({ selectedColor, className, value, onChange, colorRange, ...props }) => {
return (
<StyledWrapper color={selectedColor}>
<StyledWrapper color={selectedColor} className={className}>
<input
type="range"
min="0"
max="100"
value={value}
onChange={onChange}
className={`hue-slider ${className}`}
className="hue-slider"
style={{
background: `linear-gradient(to right, ${colorRange.join(',')})`
}}

View File

@@ -9,6 +9,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { flattenItems, isItemARequest, isItemTransientRequest } from 'utils/collections';
import filter from 'lodash/filter';
import { get } from 'lodash';
import { formatIpcError } from 'utils/common/error';
const REQUEST_TYPE = {
HTTP: 'http',
@@ -57,7 +58,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
const collection = useMemo(() => {
return collections?.find((c) => c.uid === collectionUid);
}, [collections]);
}, [collections, collectionUid]);
const collectionPresets = useMemo(() => {
return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
@@ -103,7 +104,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGraphQLRequest = useCallback(() => {
@@ -130,7 +131,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
}
}
})
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateWebSocketRequest = useCallback(() => {
@@ -149,7 +150,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGrpcRequest = useCallback(() => {
@@ -167,7 +168,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleItemClick = (type) => {

View File

@@ -64,6 +64,89 @@ const LogTimestamp = ({ timestamp }) => {
return <span className="log-timestamp">{time}</span>;
};
// Helper function to check if an object is a plain object (not a class instance)
const isPlainObject = (obj) => {
if (typeof obj !== 'object' || obj === null) return false;
const proto = Object.getPrototypeOf(obj);
return proto === null || proto === Object.prototype;
};
// Helper function to transform Bruno special types back to readable format
// Extracted outside component to avoid recreation on every render
const transformBrunoTypes = (obj, seen = new WeakSet()) => {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// Guard against circular references
if (seen.has(obj)) {
return '[Circular]';
}
seen.add(obj);
// Handle Bruno special types
if (obj.__brunoType) {
switch (obj.__brunoType) {
case 'Set':
// Transform Set to display values at top level with numeric indices
if (Array.isArray(obj.__brunoValue)) {
return Object.fromEntries(
obj.__brunoValue.map((value, index) => [index, transformBrunoTypes(value, seen)])
);
}
return {};
case 'Map':
// Transform Map to display entries at top level with => notation
if (Array.isArray(obj.__brunoValue)) {
const mapEntries = {};
for (const entry of obj.__brunoValue) {
// Defensive check: ensure entry is a valid [key, value] pair
if (Array.isArray(entry) && entry.length >= 2) {
const [key, value] = entry;
mapEntries[`${String(key)} =>`] = transformBrunoTypes(value, seen);
}
}
return mapEntries;
}
return {};
case 'Function':
return `[Function: ${obj.__brunoValue?.split?.('\n')?.[0]?.substring(0, 50) ?? 'anonymous'}...]`;
case 'undefined':
return 'undefined';
default:
return obj;
}
}
// Handle arrays - recurse into elements
if (Array.isArray(obj)) {
return obj.map((item) => transformBrunoTypes(item, seen));
}
// Preserve non-plain objects (Date, Error, RegExp, class instances, etc.)
if (!isPlainObject(obj)) {
return obj;
}
// Only deep-clone plain objects
const transformed = {};
for (const [key, value] of Object.entries(obj)) {
transformed[key] = transformBrunoTypes(value, seen);
}
return transformed;
};
// Helper to get metadata about Bruno types for display purposes
const getBrunoTypeMetadata = (obj) => {
if (typeof obj !== 'object' || obj === null) {
return {};
}
if (obj.__brunoType === 'Set' || obj.__brunoType === 'Map') {
return { type: obj.__brunoType };
}
return {};
};
const LogMessage = ({ message, args }) => {
const { displayedTheme } = useTheme();
@@ -71,18 +154,30 @@ const LogMessage = ({ message, args }) => {
if (originalArgs && originalArgs.length > 0) {
return originalArgs.map((arg, index) => {
if (typeof arg === 'object' && arg !== null) {
const metadata = getBrunoTypeMetadata(arg);
const transformedArg = transformBrunoTypes(arg);
// Determine the name to display based on the type
let displayName = false;
let shouldCollapse = 1; // Default: collapse at depth 1 for regular objects
if (metadata.type === 'Map' || metadata.type === 'Set') {
displayName = metadata.type;
shouldCollapse = true; // Fully collapse Maps/Sets by default
}
return (
<div key={index} className="log-object">
<ReactJson
src={arg}
src={transformedArg}
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
iconStyle="triangle"
indentWidth={2}
collapsed={1}
collapsed={shouldCollapse}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
name={false}
name={displayName}
style={{
backgroundColor: 'transparent',
fontSize: '${(props) => props.theme.font.size.sm}',

View File

@@ -85,6 +85,17 @@ const Wrapper = styled.div`
justify-content: center;
}
.dropdown-tab-count {
margin-left: auto;
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 10px;
background: ${(props) => props.theme.dropdown.hoverBg};
min-width: 18px;
text-align: center;
}
&:hover:not(:disabled):not(.disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}

View File

@@ -129,6 +129,7 @@ const StyledWrapper = styled.div`
text-align: center;
vertical-align: middle;
line-height: 1;
text-overflow: clip;
input[type='checkbox'] {
vertical-align: baseline;
@@ -138,6 +139,9 @@ const StyledWrapper = styled.div`
.tooltip-mod {
max-width: 200px !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: normal !important;
}
input[type='text'] {

View File

@@ -96,6 +96,36 @@ const Wrapper = styled.div`
max-width: 200px !important;
}
.name-cell-wrapper {
position: relative;
width: 100%;
.name-highlight-overlay {
position: absolute;
inset: 0;
pointer-events: none;
white-space: pre;
overflow: hidden;
font-size: inherit;
line-height: inherit;
color: ${(props) => props.theme.text};
}
}
.search-highlight {
background: ${(props) => props.theme.colors.accent}55;
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
.no-results {
padding: 24px;
text-align: center;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
input[type='text'] {
width: 100%;
border: 1px solid transparent;

View File

@@ -13,6 +13,7 @@ import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import { stripEnvVarUid } from 'utils/environments';
const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
@@ -30,6 +31,15 @@ const TableRow = React.memo(
}
);
const highlightText = (text, query) => {
if (!query?.trim() || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
);
};
const EnvironmentVariablesTable = ({
environment,
collection,
@@ -41,7 +51,8 @@ const EnvironmentVariablesTable = ({
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme } = useTheme();
const { storedTheme, theme } = useTheme();
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
@@ -49,6 +60,7 @@ const EnvironmentVariablesTable = ({
const [tableHeight, setTableHeight] = useState(MIN_H);
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
const [resizing, setResizing] = useState(null);
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -92,6 +104,7 @@ const EnvironmentVariablesTable = ({
}, []);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
@@ -167,11 +180,13 @@ const EnvironmentVariablesTable = ({
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
const variablesReloaded = !isMount && !envChanged && prevEnvVariablesRef.current !== environment.variables;
prevEnvUidRef.current = environment.uid;
prevEnvVariablesRef.current = environment.variables;
mountedRef.current = true;
if ((isMount || envChanged) && hasDraftForThisEnv && draft?.variables) {
if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
formik.setValues([
...draft.variables,
{
@@ -184,16 +199,16 @@ const EnvironmentVariablesTable = ({
}
]);
}
}, [environment.uid, hasDraftForThisEnv, draft?.variables]);
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
const savedValuesJson = useMemo(() => {
return JSON.stringify(environment.variables || []);
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
}, [environment.variables]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, savedValuesJson, setIsModified]);
@@ -202,11 +217,11 @@ const EnvironmentVariablesTable = ({
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const hasActualChanges = currentValuesJson !== savedValuesJson;
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : null;
if (hasActualChanges) {
if (currentValuesJson !== existingDraftJson) {
@@ -318,7 +333,8 @@ const EnvironmentVariablesTable = ({
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
// Compare without UIDs since they can be different but the actual data is the same
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
if (!hasChanges) {
toast.error('No changes to save');
return;
@@ -402,132 +418,160 @@ const EnvironmentVariablesTable = ({
const query = searchQuery.toLowerCase().trim();
return allVariables.filter(({ variable, index }) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return true;
}
return allVariables.filter(({ variable }) => {
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery]);
const isSearchActive = !!searchQuery?.trim();
return (
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td style={{ width: columnWidths.name }}>
Name
<div
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, 'name')}
/>
</td>
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(index, item) => item.variable.uid}
itemContent={(index, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
{isSearchActive && filteredVariables.length === 0 ? (
<div className="no-results">No results found for &ldquo;{searchQuery.trim()}&rdquo;</div>
) : (
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onBlur={() => handleNameBlur(actualIndex)}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
Name
<div
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, 'name')}
/>
</td>
<td className="flex flex-row flex-nowrap items-center" style={{ width: columnWidths.value }}>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
const activeQuery = searchQuery?.trim().toLowerCase();
const valueMatchesOnly = activeQuery
&& !(variable.name?.toLowerCase().includes(activeQuery))
&& typeof variable.value === 'string'
&& variable.value.toLowerCase().includes(activeQuery);
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
/>
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
)}
</td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<div className="name-cell-wrapper">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
readOnly={isSearchActive}
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
onBlur={() => {
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
}}
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
/>
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
<div className="name-highlight-overlay">
{highlightText(variable.name || '', searchQuery)}
</div>
)}
</div>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={isSearchActive || typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
}}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
)}
<div className="button-container">
<div className="flex items-center">

View File

@@ -55,6 +55,7 @@ const StyledWrapper = styled.div`
}
.section-title {
padding-right: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;

View File

@@ -46,8 +46,8 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn
let importedCount = 0;
for (const environment of validEnvironments) {
const action = isGlobal
? addGlobalEnvironment({ name: environment.name, variables: environment.variables })
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
? addGlobalEnvironment({ name: environment.name, variables: environment.variables, color: environment.color })
: importEnvironment({ name: environment.name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid });
await dispatch(action);
importedCount++;

View File

@@ -4,7 +4,14 @@ import Modal from 'components/Modal';
import Portal from 'components/Portal';
import Button from 'ui/Button';
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => {
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal, isDotEnv }) => {
let settingsLabel = 'collection environment settings';
if (isDotEnv) {
settingsLabel = '.env file';
} else if (isGlobal) {
settingsLabel = 'global environment settings';
}
return (
<Portal>
<Modal
@@ -21,7 +28,7 @@ const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose,
<h1 className="ml-2 text-lg font-medium">Hold on...</h1>
</div>
<div className="font-normal mt-4">
You have unsaved changes in {isGlobal ? 'global' : 'collection'} environment settings.
You have unsaved changes in {settingsLabel}.
</div>
<div className="flex justify-between mt-6">

View File

@@ -217,10 +217,12 @@ const DotEnvFileEditor = ({
];
formik.resetForm({ values: newValues });
setIsModified(false);
window.dispatchEvent(new Event('dotenv-save-complete'));
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
window.dispatchEvent(new Event('dotenv-save-failed'));
})
.finally(() => {
setIsSaving(false);
@@ -240,10 +242,12 @@ const DotEnvFileEditor = ({
.then(() => {
toast.success('Changes saved successfully');
setIsModified(false);
window.dispatchEvent(new Event('dotenv-save-complete'));
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
window.dispatchEvent(new Event('dotenv-save-failed'));
})
.finally(() => {
setIsSaving(false);

View File

@@ -39,7 +39,7 @@ const EnvironmentListContent = ({
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<ColorBadge color={env.color} size={8} showEmptyBorder={false} />
<ColorBadge color={env.color} size={8} />
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}

View File

@@ -1,7 +1,6 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -11,7 +10,7 @@ import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
const dispatch = useDispatch();
const environments = collection?.environments || [];
@@ -20,11 +19,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const searchInputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
@@ -135,13 +130,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
};
const handleColorChange = (color) => {
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid))
.then(() => {
toast.success('Environment color updated!');
})
.catch(() => {
toast.error('An error occurred while updating the environment color');
});
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
};
return (

View File

@@ -32,19 +32,6 @@ const StyledWrapper = styled.div`
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px 16px;
.title {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
color: ${(props) => props.theme.text};
margin: 0;
}
.btn-action {
display: flex;
align-items: center;
@@ -66,35 +53,54 @@ const StyledWrapper = styled.div`
}
}
.search-container {
.env-list-search {
position: relative;
padding: 0 12px 12px 12px;
.search-icon {
display: flex;
align-items: center;
margin: 0 4px 6px 4px;
.env-list-search-icon {
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-100%);
left: 8px;
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.search-input {
.env-list-search-input {
width: 100%;
padding: 6px 8px 6px 28px;
padding: 5px 24px 5px 26px;
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: all 0.15s ease;
transition: border-color 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.colors.accent};
}
}
.env-list-search-clear {
position: absolute;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
border-radius: 3px;
&:hover {
color: ${(props) => props.theme.text};
}
}
}
@@ -130,6 +136,10 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&.active {
color: ${(props) => props.theme.colors.accent};
}
}
.environment-item {

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import usePrevious from 'hooks/usePrevious';
import useOnClickOutside from 'hooks/useOnClickOutside';
import useDebounce from 'hooks/useDebounce';
import EnvironmentDetails from './EnvironmentDetails';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
import Button from 'ui/Button';
@@ -22,6 +23,8 @@ import {
createDotEnvFile,
deleteDotEnvFile
} from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import classnames from 'classnames';
@@ -39,9 +42,15 @@ const EnvironmentList = ({
setShowExportModal
}) => {
const dispatch = useDispatch();
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.collection?.query ?? '');
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.collection?.expanded ?? false);
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'collection', query: q }));
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'collection', expanded: v }));
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
const envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
const [newEnvName, setNewEnvName] = useState('');
@@ -64,6 +73,9 @@ const EnvironmentList = ({
const dotEnvInputRef = useRef(null);
const dotEnvCreateContainerRef = useRef(null);
const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300);
const envSearchInputRef = useRef(null);
const dotEnvFiles = useSelector((state) => {
const coll = state.collections.collections.find((c) => c.uid === collection?.uid);
return coll?.dotEnvFiles || EMPTY_ARRAY;
@@ -72,11 +84,24 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
dispatch(setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, [dispatch, collection.uid, selectedDotEnvFile]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
setSelectedDotEnvFile(null);
setActiveView('environment');
setIsDotEnvModified(false);
handleDotEnvModifiedChange(false);
return;
}
@@ -424,7 +449,7 @@ const EnvironmentList = ({
dispatch(deleteDotEnvFile(collection.uid, filename))
.then(() => {
toast.success(`${filename} file deleted!`);
setIsDotEnvModified(false);
handleDotEnvModifiedChange(false);
if (selectedDotEnvFile === filename) {
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
if (remainingFiles.length > 0) {
@@ -467,7 +492,7 @@ const EnvironmentList = ({
onSave={handleSaveDotEnv}
onSaveRaw={handleSaveDotEnvRaw}
isModified={isDotEnvModified}
setIsModified={setIsDotEnvModified}
setIsModified={handleDotEnvModifiedChange}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
collection={collection}
@@ -483,6 +508,12 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -517,20 +548,6 @@ const EnvironmentList = ({
)}
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<div className="sections-container">
<CollapsibleSection
@@ -539,6 +556,19 @@ const EnvironmentList = ({
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<button
type="button"
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
onClick={() => {
const next = !isEnvListSearchExpanded;
setIsEnvListSearchExpanded(next);
if (!next) setSearchText('');
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
}}
title="Search environments"
>
<IconSearch size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
@@ -551,6 +581,28 @@ const EnvironmentList = ({
</>
)}
>
{isEnvListSearchExpanded && (
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
)}
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div

View File

@@ -34,23 +34,36 @@ class ErrorBoundary extends Component {
const serializeArgs = (args) => {
return args.map((arg) => {
const seen = new WeakSet();
const replacer = (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular Reference]';
}
seen.add(value);
if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) {
const error = {};
Object.getOwnPropertyNames(value).forEach((prop) => {
error[prop] = value[prop];
});
return error;
}
}
return value;
};
try {
if (arg === null) return 'null';
if (arg === undefined) return 'undefined';
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
return arg;
}
if (arg instanceof Error) {
return {
__type: 'Error',
name: arg.name,
message: arg.message,
stack: arg.stack
};
}
if (typeof arg === 'object') {
try {
return JSON.parse(JSON.stringify(arg));
return JSON.parse(JSON.stringify(arg, replacer));
} catch {
return String(arg);
}

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
@@ -75,6 +76,10 @@ const Script = ({ collection, folder }) => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const items = flattenItems(folder.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -85,11 +90,15 @@ const Script = ({ collection, folder }) => {
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
</TabsTrigger>
</TabsList>

View File

@@ -0,0 +1,62 @@
import React from 'react';
import Modal from 'components/Modal/index';
import Portal from 'components/Portal/index';
const getOSName = () => {
const platform = window.navigator.userAgentData?.platform || '';
if (platform.startsWith('Win')) {
return 'Windows';
} else if (platform.startsWith('Mac')) {
return 'macOS';
} else if (platform.startsWith('Linux')) {
return 'Linux';
} else {
return 'your OS';
}
};
const getDownloadUrl = (os) => {
switch (os) {
case 'Windows':
return 'https://git-scm.com/download/win';
case 'macOS':
return 'https://git-scm.com/download/mac';
case 'Linux':
return 'https://git-scm.com/download/linux';
default:
return 'https://git-scm.com/download';
}
};
const GitNotFoundModal = ({ onClose }) => {
const osName = getOSName();
const downloadUrl = getDownloadUrl(osName);
return (
<Portal>
<Modal
size="sm"
title="Git Not Found"
handleCancel={onClose}
hideFooter={true}
>
<div>
<p>Git was not detected on your system. You need to install Git to proceed.</p>
<p className="mt-2">
You can download Git for <strong>{osName}</strong> here:
</p>
<p>
<span
className="text-blue-600 cursor-pointer border-b border-blue-600"
onClick={() => window.open(downloadUrl, '_blank')}
>
Download Git for {osName}
</span>
</p>
</div>
</Modal>
</Portal>
);
};
export default GitNotFoundModal;

View File

@@ -8,7 +8,37 @@ import React, { useState } from 'react';
import HelpIcon from 'components/Icons/Help';
import StyledWrapper from './StyledWrapper';
const Help = ({ children, width = 200 }) => {
const getPlacementStyles = (placement) => {
switch (placement) {
case 'top':
return {
bottom: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)'
};
case 'bottom':
return {
top: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)'
};
case 'left':
return {
top: '50%',
right: 'calc(100% + 8px)',
transform: 'translateY(-50%)'
};
case 'right':
default:
return {
top: '50%',
left: 'calc(100% + 8px)',
transform: 'translateY(-50%)'
};
}
};
const Help = ({ children, width = 200, placement = 'right' }) => {
const [showTooltip, setShowTooltip] = useState(false);
return (
@@ -24,9 +54,7 @@ const Help = ({ children, width = 200 }) => {
<StyledWrapper
className="absolute z-50 rounded-md p-3"
style={{
top: '50%',
left: 'calc(100% + 8px)',
transform: 'translateY(-50%)',
...getPlacementStyles(placement),
width: `${width}px`
}}
>

View File

@@ -3,8 +3,9 @@ import { useSelector, useDispatch } from 'react-redux';
import { IconArrowLeft, IconPlus, IconFolder, IconLock, IconDots, IconCategory, IconLogin } from '@tabler/icons';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { createWorkspaceWithUniqueName, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { sortWorkspaces } from 'utils/workspaces';
@@ -59,6 +60,21 @@ const ManageWorkspace = () => {
setDeleteWorkspaceModal({ open: true, workspace });
};
const handleCreateWorkspace = async () => {
const defaultLocation = get(preferences, 'general.defaultLocation', '');
if (!defaultLocation) {
setCreateWorkspaceModalOpen(true);
return;
}
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}
};
return (
<StyledWrapper>
{createWorkspaceModalOpen && (
@@ -86,7 +102,7 @@ const ManageWorkspace = () => {
</div>
<span className="header-title">Manage Workspace</span>
</div>
<Button size="sm" onClick={() => setCreateWorkspaceModalOpen(true)} icon={<IconPlus size={14} strokeWidth={2} />}>
<Button size="sm" onClick={handleCreateWorkspace} icon={<IconPlus size={14} strokeWidth={2} />}>
Create Workspace
</Button>
</div>

View File

@@ -15,20 +15,20 @@ const StyledMarkdownBodyWrapper = styled.div`
margin: 0.67em 0;
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 1.4em;
font-size: 2.2em;
border-bottom: 1px solid var(--color-border-muted);
}
h2 {
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 1.3em;
font-size: 1.7em;
border-bottom: 1px solid var(--color-border-muted);
}
h3 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 1.2em;
font-size: 1.45em;
}
h4 {
@@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div`
h5 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 1em;
font-size: 0.975em;
}
h6 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 0.9em;
font-size: 0.85em;
color: var(--color-fg-muted);
}

View File

@@ -6,6 +6,7 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -24,6 +25,9 @@ class MultiLineEditor extends Component {
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -45,16 +49,16 @@ class MultiLineEditor extends Component {
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
// 'Ctrl-Enter': () => {
// if (this.props.onRun) {
// this.props.onRun();
// }
// },
// 'Cmd-Enter': () => {
// if (this.props.onRun) {
// this.props.onRun();
// }
// },
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
@@ -90,6 +94,9 @@ class MultiLineEditor extends Component {
setupLinkAware(this.editor);
// Setup keyboard shortcuts
this._shortcutsCleanup = setupShortcuts(this.editor, this);
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
@@ -154,10 +161,17 @@ class MultiLineEditor extends Component {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = String(this.props.value ?? '');
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
@@ -172,6 +186,12 @@ class MultiLineEditor extends Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}

View File

@@ -0,0 +1,13 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
form.bruno-form {
label {
font-size: 0.8125rem;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,122 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import {
savePreferences,
clearHttpHttpsAgentCache
} from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
import get from 'lodash/get';
const cacheSchema = Yup.object().shape({
sslSession: Yup.object({
enabled: Yup.boolean()
})
});
const Cache = () => {
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
const handleSave = useCallback(
(newCachePreferences) => {
dispatch(
savePreferences({
...preferences,
cache: newCachePreferences
})
).catch(() => toast.error('Failed to update cache preferences'));
},
[dispatch, preferences]
);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const formik = useFormik({
initialValues: {
sslSession: {
enabled: get(preferences, 'cache.sslSession.enabled', false)
}
},
validationSchema: cacheSchema,
onSubmit: async (values) => {
try {
const newPreferences = await cacheSchema.validate(values, { abortEarly: true });
handleSave(newPreferences);
} catch (error) {
console.error('Cache preferences validation error:', error.message);
}
}
});
const debouncedSave = useCallback(
debounce((values) => {
cacheSchema
.validate(values, { abortEarly: true })
.then((validatedValues) => handleSaveRef.current(validatedValues))
.catch(() => {});
}, 500),
[]
);
useEffect(() => {
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
const handleAgentCachingChange = (e) => {
formik.handleChange(e);
// Immediately evict all cached agents when caching is disabled
if (!e.target.checked) {
dispatch(clearHttpHttpsAgentCache()).catch(() => {});
}
};
const handleResetCache = () => {
dispatch(clearHttpHttpsAgentCache())
.then(() => toast.success('ssl session cache cleared'))
.catch(() => toast.error('Failed to clear ssl session cache'));
};
return (
<StyledWrapper className="w-full">
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
<div className="flex items-center my-2">
<input
id="sslSession.enabled"
type="checkbox"
name="sslSession.enabled"
checked={formik.values.sslSession.enabled}
onChange={handleAgentCachingChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
Enable SSL session caching
</label>
</div>
<div className="text-xs mt-1 ml-6 opacity-70">
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every
request.
</div>
<div className="mt-6">
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
Clear
</button>
</div>
</form>
</StyledWrapper>
);
};
export default Cache;

View File

@@ -0,0 +1,127 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.zoom-field {
width: 120px;
position: relative;
}
.zoom-field label {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
display: block;
}
.custom-select {
width: 80px;
height: 35.89px;
padding: 0 0.5rem;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: ${(props) => props.theme.input.background};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.text};
font-size: 0.875rem;
line-height: 1.5;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.custom-select:hover {
border-color: ${(props) => props.theme.input.hoverBorder || props.theme.input.border};
}
.custom-select .selected-value {
flex: 1;
}
.custom-select .chevron-icon {
color: ${(props) => props.theme.input.border};
flex-shrink: 0;
transition: transform 0.15s ease;
margin-left: auto;
}
.dropdown-menu {
width: 80px;
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background-color: ${(props) => props.theme.input.background};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 50;
max-height: 200px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.dropdown-menu::-webkit-scrollbar {
display: none;
}
.dropdown-option {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.1s ease;
}
.dropdown-option:hover {
background-color: ${(props) => props.theme.input.border};
}
.dropdown-option.selected {
background-color: ${(props) => props.theme.input.focusBorder || props.theme.input.border}22;
}
.dropdown-option .option-label {
flex: 1;
}
.dropdown-option .check-icon {
color: ${(props) => props.theme.textLink};
flex-shrink: 0;
}
.reset-btn {
padding: 0.45rem 1rem;
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.textLink};
font-size: 0.875rem;
line-height: 1.5;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
background: ${(props) => props.theme.input.border};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
box-shadow: 0 0 0 2px ${(props) => props.theme.input.focusBorder}33;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,124 @@
import React, { useState, useRef, useEffect } from 'react';
import get from 'lodash/get';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { IconChevronDown, IconCheck } from '@tabler/icons';
const { percentageToZoomLevel } = require('@usebruno/common');
// Zoom options for dropdown (50% to 150%)
const ZOOM_OPTIONS = [
{ label: '50%', value: 50 },
{ label: '60%', value: 60 },
{ label: '70%', value: 70 },
{ label: '80%', value: 80 },
{ label: '90%', value: 90 },
{ label: '100%', value: 100 },
{ label: '110%', value: 110 },
{ label: '120%', value: 120 },
{ label: '130%', value: 130 },
{ label: '140%', value: 140 },
{ label: '150%', value: 150 }
];
const DEFAULT_ZOOM = 100;
const Zoom = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const dropdownRef = useRef(null);
const dropdownMenuRef = useRef(null);
const { ipcRenderer } = window;
// Get saved zoom percentage from Redux preferences (single source of truth)
const savedZoom = get(preferences, 'display.zoomPercentage', DEFAULT_ZOOM);
const [isOpen, setIsOpen] = useState(false);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Callback ref to scroll to selected option when dropdown renders
const setDropdownMenuRef = (node) => {
dropdownMenuRef.current = node;
if (node) {
const selectedOption = node.querySelector('.dropdown-option.selected');
if (selectedOption) {
selectedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
};
const handleSelect = (zoom) => {
// Apply zoom level to Electron window immediately
if (ipcRenderer) {
const zoomLevel = percentageToZoomLevel(zoom);
ipcRenderer.invoke('renderer:set-zoom-level', zoomLevel);
}
// Save to preferences via Redux (same pattern as layout)
const updatedPreferences = {
...preferences,
display: {
...get(preferences, 'display', {}),
zoomPercentage: zoom
}
};
dispatch(savePreferences(updatedPreferences));
setIsOpen(false);
};
const handleResetToDefault = () => {
handleSelect(DEFAULT_ZOOM);
};
const selectedOption = ZOOM_OPTIONS.find((opt) => opt.value === savedZoom);
const isDefault = savedZoom === DEFAULT_ZOOM;
return (
<StyledWrapper>
<div className="flex flex-row gap-1 items-end">
<div className="zoom-field" ref={dropdownRef}>
<label className="block">Interface Zoom</label>
<div className="custom-select mt-2" onClick={() => setIsOpen(!isOpen)}>
<span className="selected-value">{selectedOption?.label}</span>
<IconChevronDown size={14} className="chevron-icon" />
</div>
{isOpen && (
<div className="dropdown-menu" ref={setDropdownMenuRef}>
{ZOOM_OPTIONS.map((option) => (
<div
key={option.value}
className={`dropdown-option ${option.value === savedZoom ? 'selected' : ''}`}
onClick={() => handleSelect(option.value)}
>
<span className="option-label">{option.label}</span>
{option.value === savedZoom && <IconCheck size={12} className="check-icon" />}
</div>
))}
</div>
)}
</div>
{!isDefault && (
<button
type="button"
className="reset-btn"
onClick={handleResetToDefault}
>
Reset
</button>
)}
</div>
</StyledWrapper>
);
};
export default Zoom;

View File

@@ -1,5 +1,6 @@
import React from 'react';
import Font from './Font/index';
import Zoom from './Zoom/index';
const Display = ({ close }) => {
return (
@@ -9,6 +10,9 @@ const Display = ({ close }) => {
<div className="w-fit flex flex-col gap-2">
<Font close={close} />
</div>
<div className="w-full flex flex-col gap-2">
<Zoom />
</div>
</div>
</div>
);

View File

@@ -24,7 +24,7 @@ const StyledWrapper = styled.div`
}
}
.default-collection-location-input {
.default-location-input {
max-width: 28rem;
}
`;

View File

@@ -47,8 +47,8 @@ const General = () => {
.test('isNumber', 'Save Delay must be a number', (value) => {
return value === undefined || !isNaN(value);
})
.test('isValidInterval', 'Save Delay must be at least 100ms', (value) => {
return value === undefined || Number(value) >= 100;
.test('isValidInterval', 'Save Delay must be at least 500ms', (value) => {
return value === undefined || Number(value) >= 500;
})
}).test('intervalRequired', 'Save Delay is required when Auto Save is enabled', (value) => {
// If autosave is enabled, interval must be provided
@@ -60,7 +60,7 @@ const General = () => {
oauth2: Yup.object({
useSystemBrowser: Yup.boolean()
}),
defaultCollectionLocation: Yup.string().max(1024)
defaultLocation: Yup.string().max(1024)
});
const formik = useFormik({
@@ -83,7 +83,7 @@ const General = () => {
oauth2: {
useSystemBrowser: get(preferences, 'request.oauth2.useSystemBrowser', false)
},
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
defaultLocation: get(preferences, 'general.defaultLocation', '')
},
validationSchema: preferencesSchema,
onSubmit: async (values) => {
@@ -121,7 +121,7 @@ const General = () => {
interval: newPreferences.autoSave.interval
},
general: {
defaultCollectionLocation: newPreferences.defaultCollectionLocation
defaultLocation: newPreferences.defaultLocation
}
}))
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
@@ -163,11 +163,11 @@ const General = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
formik.setFieldValue('defaultCollectionLocation', dirPath);
formik.setFieldValue('defaultLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('defaultCollectionLocation', '');
formik.setFieldValue('defaultLocation', '');
console.error(error);
});
};
@@ -356,35 +356,38 @@ const General = () => {
<div className="text-red-500">{formik.errors.autoSave.interval}</div>
)}
<div className="flex flex-col mt-6">
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
Default Collection Location
<label className="block select-none default-location-label" htmlFor="defaultLocation">
Default Location
</label>
<p className="text-muted mt-1 text-xs">
Used as the default location for new workspaces and collections
</p>
<input
type="text"
name="defaultCollectionLocation"
id="defaultCollectionLocation"
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
name="defaultLocation"
id="defaultLocation"
className="block textbox mt-2 w-full cursor-pointer default-location-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
readOnly={true}
onChange={formik.handleChange}
value={formik.values.defaultCollectionLocation || ''}
value={formik.values.defaultLocation || ''}
onClick={browseDefaultLocation}
placeholder="Click to browse for default location"
/>
<div className="mt-1">
<span
className="text-link cursor-pointer hover:underline default-collection-location-browse"
className="text-link cursor-pointer hover:underline default-location-browse"
onClick={browseDefaultLocation}
>
Browse
</span>
</div>
</div>
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
{formik.touched.defaultLocation && formik.errors.defaultLocation ? (
<div className="text-red-500">{formik.errors.defaultLocation}</div>
) : null}
</form>
</StyledWrapper>

View File

@@ -1,53 +1,198 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
table {
width: 80%;
border-collapse: collapse;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
-ms-overflow-style: none;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
}
.reset-all-btn {
display: flex;
align-items: center;
background: transparent;
border: 1px solid ${(props) => props.theme.table.border};
border-radius: 6px;
padding: 4px 4px;
cursor: pointer;
color: ${(props) => props.theme.text};
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
background: ${(props) => props.theme.button.secondary.hoverBg};
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
}
}
.keybinding-row {
display: flex;
align-items: center;
gap: 10px;
}
.keybinding-row:hover .edit-btn {
opacity: 0.9;
}
.shortcut-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 260px;
flex: 1;
}
.shortcut-input {
width: 200px;
max-width: 200px;
flex-shrink: 0;
caret-color: ${(props) => props.theme.table.input.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: none;
outline: none;
background: transparent;
font-family: monospace;
color: ${(props) => props.theme.table.input.color};
cursor: pointer;
&:hover {
opacity: 0.85;
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
&:focus {
opacity: 1;
}
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
&::placeholder {
opacity: 0.5;
}
}
thead th {
font-weight: 500;
padding: 10px;
text-align: left;
border: 1px solid ${(props) => props.theme.table.border};
.edit-btn {
background: transparent;
border: none;
color: ${(props) => props.theme.table.thead.color};
padding: 0;
cursor: pointer;
opacity: 0.6;
&:hover {
opacity: 1;
}
}
.reset-btn {
background: transparent;
border: none;
color: ${(props) => props.theme.table.thead.color};
border-radius: 8px;
padding: 0px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.shortcut-input--error {
opacity: 1;
}
.kb-tooltip {
border-radius: 8px;
padding: 6px 8px;
font-size: 12px;
line-height: 1.2;
max-width: 320px;
white-space: normal;
}
.kb-tooltip--error {
color: ${(props) => props.theme.colors?.text?.red || '#ef4444'};
}
.table-container {
flex: 1 1 auto;
min-height: 0;
max-height: 650px;
overflow-y: auto;
border-radius: 8px;
border-top: 1px solid ${(props) => props.theme.table.border};
border-bottom: 1px solid ${(props) => props.theme.table.border};
&::-webkit-scrollbar {
width: 0;
height: 0;
}
scrollbar-width: none;
-ms-overflow-style: none;
}
.key-button {
display: inline-block;
color: ${(props) => props.theme.table.input.color};
opacity: 0.7;
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;
margin-right: 8px;
border: 1px solid #ccc;
border-bottom: 1.44px solid ${(props) => props.theme.table.input.border};
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
thead th:first-child,
tbody td:first-child {
width: 35%;
}
thead th:last-child,
tbody td:last-child {
width: 45%;
}
thead th {
position: sticky;
top: 0;
z-index: 5;
background: ${(props) => props.theme.background};
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
font-weight: 500;
padding: 10px;
text-align: left;
border-left: 1px solid ${(props) => props.theme.table.border};
border-right: 1px solid ${(props) => props.theme.table.border};
border-bottom: 1px solid ${(props) => props.theme.table.border};
box-shadow: 0 1px 0 ${(props) => props.theme.table.border};
}
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
border-top: 1px solid ${(props) => props.theme.table.border};
border-left: 1px solid ${(props) => props.theme.table.border};
border-right: 1px solid ${(props) => props.theme.table.border};
}
`;

View File

@@ -1,14 +1,524 @@
import React, { useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import React from 'react';
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
import { IconRefresh, IconPencil } from '@tabler/icons';
import { isMacOS } from 'utils/common/platform';
const Keybindings = ({ close }) => {
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
import { savePreferences } from 'providers/ReduxStore/slices/app';
import { DEFAULT_KEY_BINDINGS } from 'providers/Hotkeys/keyMappings.js';
import { Tooltip } from 'react-tooltip';
const SEP = '+bind+';
const getOS = () => (isMacOS() ? 'mac' : 'windows');
// Stored tokens must match your preferences defaults (lowercase)
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
const REQUIRED_MODIFIERS_BY_OS = {
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
windows: new Set(['ctrl', 'alt', 'shift']) // command (Win key) should NOT count
};
const hasRequiredModifier = (os, arr) => arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
const sortCombo = (arr) => {
const order = ['ctrl', 'command', 'alt', 'shift'];
const modifiers = [];
const nonModifiers = [];
// Separate modifiers from non-modifiers
arr.forEach((key) => {
if (order.includes(key)) {
modifiers.push(key);
} else {
nonModifiers.push(key);
}
});
// Sort modifiers by their order
modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b));
// Keep non-modifiers in the order they were pressed (don't sort them)
return [...modifiers, ...nonModifiers];
};
const uniqSorted = (arr) => {
// Remove duplicates while preserving order
const unique = [];
const seen = new Set();
arr.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
unique.push(key);
}
});
return sortCombo(unique);
};
const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []);
const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP);
// Signature MUST be stable: unique + sorted
const comboSignature = (arr) => toKeysString(arr);
// OS reserved shortcuts in stored-token format
const RESERVED_BY_OS = {
mac: new Set([
comboSignature(['command', 'q']),
comboSignature(['command', 'w']),
comboSignature(['command', 'h']),
comboSignature(['command', 'm']),
comboSignature(['command', 'tab']),
comboSignature(['command', 'space']),
comboSignature(['ctrl', 'command', 'q']),
comboSignature(['command', ',']),
comboSignature(['command', 'shift', '3']),
comboSignature(['command', 'shift', '4']),
comboSignature(['command', 'shift', '5']),
comboSignature(['command', 'alt', 'esc'])
]),
windows: new Set([
comboSignature(['alt', 'tab']),
comboSignature(['alt', 'f4']),
comboSignature(['ctrl', 'alt', 'delete']),
comboSignature(['command', 'l']),
comboSignature(['command', 'd']),
comboSignature(['command', 'e']),
comboSignature(['command', 'r']),
comboSignature(['command', 'tab']),
comboSignature(['ctrl', 'shift', 'esc'])
])
};
// normalize keyboard event -> stored tokens
const normalizeKey = (e) => {
const k = e.key;
// ignore lock keys
if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null;
if (k === ' ') return 'space';
if (k === 'Escape') return 'esc';
if (k === 'Control') return 'ctrl';
if (k === 'Alt') return 'alt';
if (k === 'Shift') return 'shift';
if (k === 'Enter') return 'enter';
if (k === 'Backspace') return 'backspace';
if (k === 'Tab') return 'tab';
if (k === 'Delete') return 'delete';
// Meta -> command (matches your stored default format)
if (k === 'Meta') return 'command';
// single char (letters/punct) to lowercase
if (k.length === 1) return k.toLowerCase();
// ArrowUp -> arrowup, PageUp -> pageup, etc
return k.toLowerCase();
};
const ERROR = {
EMPTY: 'EMPTY',
ONLY_MODIFIERS: 'ONLY_MODIFIERS',
MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD',
RESERVED: 'RESERVED',
DUPLICATE: 'DUPLICATE',
CONFLICT: 'CONFLICT'
};
const Keybindings = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const os = getOS();
// Source of truth: merge defaults with user preferences
const keyBindings = useMemo(() => {
const merged = {};
// Start with defaults
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
merged[action] = { ...binding };
}
// Override with user preferences
const userBindings = preferences?.keyBindings || {};
for (const [action, binding] of Object.entries(userBindings)) {
if (merged[action]) {
// Merge user's OS-specific overrides into defaults
merged[action] = {
...merged[action],
...binding
};
}
}
return merged;
}, [preferences?.keyBindings]);
// Build table data (action -> { name, keys })
const keyMapping = useMemo(() => {
const out = {};
for (const [action, binding] of Object.entries(keyBindings)) {
if (binding?.[os]) out[action] = { name: binding.name, keys: binding[os] };
}
return out;
}, [keyBindings, os]);
// ✏️ which row is allowed to edit (pencil clicked)
const [editingAction, setEditingAction] = useState(null);
// hover tracking (for showing pencil/refresh only on hover row)
const [hoveredAction, setHoveredAction] = useState(null);
// Recording state
const [recordingAction, setRecordingAction] = useState(null);
const pressedKeysRef = useRef(new Set());
const inputRefs = useRef({});
const [draftByAction, setDraftByAction] = useState({}); // action -> string[]
const [errorByAction, setErrorByAction] = useState({}); // action -> { code, message }
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
const getDefaultRowKeysString = (action) => DEFAULT_KEY_BINDINGS?.[action]?.[os] || '';
const isRowDirty = (action) => {
const current = getCurrentRowKeysString(action);
const def = getDefaultRowKeysString(action);
if (!DEFAULT_KEY_BINDINGS) return false;
return current !== def;
};
// Check if any keybinding is dirty (different from default)
const hasDirtyRows = useMemo(() => {
for (const action of Object.keys(DEFAULT_KEY_BINDINGS)) {
if (isRowDirty(action)) {
return true;
}
}
return false;
}, [keyBindings, os]);
const buildUsedSignatures = (excludeAction) => {
const used = new Set();
for (const [action, binding] of Object.entries(keyBindings)) {
if (action === excludeAction) continue;
const keysStr = binding?.[os];
if (!keysStr) continue;
used.add(comboSignature(fromKeysString(keysStr)));
}
return used;
};
const validateCombo = (action, arrRaw) => {
const arr = uniqSorted(arrRaw);
const sig = comboSignature(arr);
if (!sig) return { code: ERROR.EMPTY, message: `Shortcut cant be empty.` };
if (isOnlyModifiers(arr))
return { code: ERROR.ONLY_MODIFIERS, message: 'Add a non-modifier key (e.g. Ctrl + K).' };
// OS-specific must-have modifier rule
if (!hasRequiredModifier(os, arr)) {
return {
code: ERROR.MISSING_REQUIRED_MOD,
message:
os === 'mac'
? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).'
: 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).'
};
}
// OS reserved
if (RESERVED_BY_OS[os]?.has(sig))
return { code: ERROR.RESERVED, message: 'This shortcut is reserved by the OS.' };
// No duplicates (across all other actions)
if (buildUsedSignatures(action).has(sig))
return { code: ERROR.DUPLICATE, message: 'That shortcut is already in use.' };
// Check for subset conflicts (e.g., Cmd+A conflicts with Cmd+Z+A)
for (const [otherAction, binding] of Object.entries(keyBindings)) {
if (otherAction === action) continue;
const otherKeysStr = binding?.[os];
if (!otherKeysStr) continue;
const otherKeys = fromKeysString(otherKeysStr);
// Check if current is a subset of other (current is shorter)
if (arr.length < otherKeys.length) {
const isSubset = arr.every((k) => otherKeys.includes(k));
if (isSubset) {
return {
code: ERROR.CONFLICT,
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove the longer shortcut first.`
};
}
}
// Check if other is a subset of current (current is longer)
if (arr.length > otherKeys.length) {
const isSubset = otherKeys.every((k) => arr.includes(k));
if (isSubset) {
return {
code: ERROR.CONFLICT,
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove that shortcut first.`
};
}
}
}
return null;
};
const persistToPreferences = (action, nextKeys) => {
const updatedPreferences = {
...preferences,
keyBindings: {
...(preferences?.keyBindings || {}),
[action]: {
...(preferences?.keyBindings?.[action] || {}),
name: preferences?.keyBindings?.[action]?.name || action,
[os]: nextKeys
}
}
};
dispatch(savePreferences(updatedPreferences));
};
// Commit only if valid. Returns true if commit succeeded (or no-op), false if invalid.
const commitCombo = (action) => {
const draftArr = draftByAction[action] || [];
if (!draftArr.length) return;
const arr = uniqSorted(draftArr);
const err = validateCombo(action, arr);
if (err) {
setErrorByAction((prev) => ({ ...prev, [action]: err }));
return false;
}
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
const nextKeys = toKeysString(arr);
const currentKeys = getCurrentRowKeysString(action);
if (nextKeys === currentKeys) return true;
persistToPreferences(action, nextKeys);
// toast success for 2s with Command name
const commandName = keyBindings?.[action]?.name || action;
toast.success(`"${commandName}" shortcut updated`, { autoClose: 2000 });
return true;
};
const resetRowToDefault = (action) => {
const def = DEFAULT_KEY_BINDINGS?.[action]?.[os];
if (!def) return;
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
setDraftByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
persistToPreferences(action, def);
};
const resetAllKeybindings = () => {
const updatedPreferences = {
...preferences,
keyBindings: {}
};
dispatch(savePreferences(updatedPreferences));
};
const startEditing = (action) => {
// if another row is editing, commit/stop it first
if (editingAction && editingAction !== action) {
const ok = commitCombo(editingAction);
if (ok) {
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
} else {
// keep previous row editing if invalid
return;
}
}
setEditingAction(action);
setRecordingAction(action);
pressedKeysRef.current = new Set();
// seed draft with current value
setDraftByAction((prev) => ({
...prev,
[action]: fromKeysString(getCurrentRowKeysString(action))
}));
// clear error on start edit
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
requestAnimationFrame(() => {
inputRefs.current[action]?.focus?.();
inputRefs.current[action]?.setSelectionRange?.(
inputRefs.current[action].value.length,
inputRefs.current[action].value.length
);
});
};
const stopEditing = (action) => {
const ok = commitCombo(action);
if (!ok) {
// If commit failed (validation error), reset to original value
cancelEditing(action);
return;
}
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
};
// Reset draft to original value and clear error (used on blur with invalid state)
const cancelEditing = (action) => {
// Clear error for this action
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
// Reset draft to current saved value
setDraftByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
};
const handleKeyDown = (action, e) => {
if (recordingAction !== action || editingAction !== action) return;
e.preventDefault();
e.stopPropagation();
// allow user to clear and keep editing (do NOT auto-stop)
if (e.key === 'Backspace' || e.key === 'Delete') {
pressedKeysRef.current = new Set();
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
setErrorByAction((prev) => ({
...prev,
[action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` }
}));
return;
}
if (e.repeat) return;
const keyName = normalizeKey(e);
if (!keyName) return;
pressedKeysRef.current.add(keyName);
const currentDraft = uniqSorted(Array.from(pressedKeysRef.current));
setDraftByAction((prev) => ({
...prev,
[action]: currentDraft
}));
const err = validateCombo(action, currentDraft);
if (err) {
setErrorByAction((prev) => ({ ...prev, [action]: err }));
} else {
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
}
};
const handleKeyUp = (action, e) => {
if (recordingAction !== action || editingAction !== action) return;
e.preventDefault();
e.stopPropagation();
const keyName = normalizeKey(e);
if (!keyName) return;
pressedKeysRef.current.delete(keyName);
// commit only when released AND currently valid
if (pressedKeysRef.current.size === 0) {
const currentDraft = draftByAction[action] || [];
// if empty -> keep editing
if (currentDraft.length === 0) return;
// if error -> keep editing
if (errorByAction[action]?.message) return;
stopEditing(action);
}
};
const renderValue = (action) => {
const arr
= recordingAction === action ? draftByAction[action] : fromKeysString(getCurrentRowKeysString(action));
return (arr || []).join(' + ');
};
return (
<StyledWrapper className="w-full">
<div className="section-header">Keybindings</div>
<Tooltip
id="kb-editing-error-tooltip"
place="bottom-start"
opacity={1}
className="kb-tooltip kb-tooltip--error"
/>
<div className="section-header">
<span>Keybindings</span>
{hasDirtyRows && (
<button
type="button"
className="reset-all-btn"
onClick={resetAllKeybindings}
title="Reset all keybindings to default"
>
<IconRefresh size={12} stroke={1} />
</button>
)}
</div>
<div className="table-container">
<table>
<thead>
@@ -19,18 +529,90 @@ const Keybindings = ({ close }) => {
</thead>
<tbody>
{keyMapping ? (
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
<tr key={index}>
<td>{name}</td>
<td>
{keys.split('+').map((key, i) => (
<div className="key-button" key={i}>
{key}
Object.entries(keyMapping).map(([action, row]) => {
const isEditing = editingAction === action;
const isHovered = hoveredAction === action;
const isDirty = isRowDirty(action);
const showPencil = isHovered && !isEditing && !isDirty;
const showRefresh = isDirty && !isEditing;
const hasError = Boolean(errorByAction[action]?.message);
const errorMessage = errorByAction[action]?.message;
const inputId = `kb-input-${action}`;
return (
<tr
key={action}
data-testid={`keybinding-row-${action}`}
onMouseEnter={() => setHoveredAction(action)}
onMouseLeave={() => setHoveredAction((prev) => (prev === action ? null : prev))}
>
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
<td>
<div className="keybinding-row">
<div className="shortcut-wrap">
<input
id={inputId}
ref={(el) => {
if (el) inputRefs.current[action] = el;
}}
data-testid={`keybinding-input-${action}`}
className={`shortcut-input ${hasError ? 'shortcut-input--error' : ''}`}
value={renderValue(action)}
readOnly={!isEditing}
onKeyDown={(e) => handleKeyDown(action, e)}
onKeyUp={(e) => handleKeyUp(action, e)}
onBlur={() => {
// If there's an error, reset to original value instead of keeping invalid state
if (isEditing && hasError) {
cancelEditing(action);
} else if (isEditing) {
stopEditing(action);
}
}}
spellCheck={false}
/>
{isEditing && hasError && (
<Tooltip
id={`kb-editing-error-tooltip-${action}`}
anchorSelect={`#${inputId}`}
place="bottom-start"
opacity={1}
isOpen={true}
content={errorMessage}
className="kb-tooltip kb-tooltip--error"
/>
)}
</div>
{showRefresh && (
<button
type="button"
className="reset-btn"
data-testid={`keybinding-reset-${action}`}
onClick={() => resetRowToDefault(action)}
title="Reset to default"
>
<IconRefresh size={12} stroke={1} />
</button>
)}
{showPencil && (
<button
type="button"
className="edit-btn"
data-testid={`keybinding-edit-${action}`}
onClick={() => startEditing(action)}
title="Edit shortcut"
>
<IconPencil size={12} stroke={1.5} />
</button>
)}
</div>
))}
</td>
</tr>
))
</td>
</tr>
);
})
) : (
<tr>
<td colSpan="2">No key bindings available</td>

View File

@@ -9,7 +9,8 @@ import {
IconUserCircle,
IconKeyboard,
IconZoomQuestion,
IconSquareLetterB
IconSquareLetterB,
IconDatabase
} from '@tabler/icons';
import Support from './Support';
@@ -21,6 +22,7 @@ import Keybindings from './Keybindings';
import Beta from './Beta';
import StyledWrapper from './StyledWrapper';
import Cache from './Cache/index';
const Preferences = () => {
const dispatch = useDispatch();
@@ -65,6 +67,10 @@ const Preferences = () => {
case 'support': {
return <Support />;
}
case 'cache': {
return <Cache />;
}
}
};
@@ -92,6 +98,10 @@ const Preferences = () => {
<IconKeyboard size={16} strokeWidth={1.5} />
Keybindings
</div>
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
<IconDatabase size={16} strokeWidth={1.5} />
Cache
</div>
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
<IconZoomQuestion size={16} strokeWidth={1.5} />
Support

View File

@@ -1,10 +1,10 @@
import React, { useRef, forwardRef } from 'react';
import React from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
@@ -20,8 +20,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
const preferences = useSelector((state) => state.app.preferences);
const { storedTheme } = useTheme();
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
const oAuth = get(request, 'auth.oauth2', {});
const {
@@ -41,30 +39,13 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters
} = oAuth;
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleSave = () => { save(); };
const handleChange = (key, value) => {
@@ -91,6 +72,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters,
[key]: value
}
@@ -119,6 +101,7 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
tokenSource,
additionalParameters,
pkce: !Boolean(oAuth?.['pkce'])
}
@@ -226,26 +209,19 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
<MenuDropdown
items={[
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
]}
selectedItemId={credentialsPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</MenuDropdown>
</div>
</div>
<div className="flex flex-row w-full gap-4" key="pkce">
@@ -265,6 +241,24 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-type">
<label className="block min-w-[140px]">Token Source</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
]}
selectedItemId={tokenSource}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-name">
<label className="block min-w-[140px]">Token ID</label>
<div className="single-line-editor-wrapper flex-1">
@@ -283,26 +277,19 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
<div className="flex items-center gap-4 w-full" key="input-token-placement">
<label className="block min-w-[140px]">Add token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
<MenuDropdown
items={[
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
]}
selectedItemId={tokenPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</MenuDropdown>
</div>
</div>
{

View File

@@ -1,4 +1,4 @@
import React, { useRef, forwardRef } from 'react';
import React from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
@@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
@@ -16,8 +16,6 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
const oAuth = get(request, 'auth.oauth2', {});
@@ -34,6 +32,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters
} = oAuth;
@@ -42,24 +41,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
const handleSave = () => { save(); };
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleChange = (key, value) => {
dispatch(
updateAuth({
@@ -80,6 +61,7 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters,
[key]: value
}
@@ -126,26 +108,19 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
<MenuDropdown
items={[
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
]}
selectedItemId={credentialsPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</MenuDropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
@@ -156,6 +131,24 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-type">
<label className="block min-w-[140px]">Token Source</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
]}
selectedItemId={tokenSource}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-name">
<label className="block min-w-[140px]">Token ID</label>
<div className="single-line-editor-wrapper flex-1">
@@ -174,26 +167,19 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
<div className="flex items-center gap-4 w-full" key="input-token-placement">
<label className="block min-w-[140px]">Add token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
<MenuDropdown
items={[
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
]}
selectedItemId={tokenPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</MenuDropdown>
</div>
</div>
{

View File

@@ -1,6 +1,6 @@
import React, { useRef, forwardRef } from 'react';
import React from 'react';
import get from 'lodash/get';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { IconCaretDown, IconKey } from '@tabler/icons';
@@ -10,20 +10,10 @@ import { useState } from 'react';
const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const oAuth = get(request, 'auth.oauth2', {});
const [valuesCache, setValuesCache] = useState({
...oAuth
});
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end grant-type-label select-none">
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onGrantTypeChange = (grantType) => {
let updatedValues = {
@@ -65,7 +55,8 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
credentialsId: 'credentials',
tokenPlacement: 'header',
tokenHeaderPrefix: 'Bearer',
tokenQueryKey: 'access_token'
tokenQueryKey: 'access_token',
tokenSource: 'access_token'
}
})
);
@@ -82,44 +73,20 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
</span>
</div>
<div className="inline-flex items-center cursor-pointer grant-type-mode-selector w-fit">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('password');
}}
>
Password Credentials
<MenuDropdown
items={[
{ id: 'password', label: 'Password Credentials', onClick: () => onGrantTypeChange('password') },
{ id: 'authorization_code', label: 'Authorization Code', onClick: () => onGrantTypeChange('authorization_code') },
{ id: 'implicit', label: 'Implicit', onClick: () => onGrantTypeChange('implicit') },
{ id: 'client_credentials', label: 'Client Credentials', onClick: () => onGrantTypeChange('client_credentials') }
]}
selectedItemId={oAuth?.grantType}
placement="bottom-end"
>
<div className="flex items-center justify-end grant-type-label select-none">
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('authorization_code');
}}
>
Authorization Code
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('implicit');
}}
>
Implicit
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('client_credentials');
}}
>
Client Credentials
</div>
</Dropdown>
</MenuDropdown>
</div>
</StyledWrapper>
);

View File

@@ -1,9 +1,9 @@
import React, { useRef, forwardRef, useMemo } from 'react';
import React, { useMemo } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import Wrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
@@ -20,9 +20,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
const preferences = useSelector((state) => state.app.preferences);
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = get(request, 'auth.oauth2', {});
const {
callbackUrl,
@@ -34,7 +31,8 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken
autoFetchToken,
tokenSource
} = oAuth;
const interpolatedAuthUrl = useMemo(() => {
@@ -42,15 +40,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
return interpolate(authorizationUrl, variables);
}, [collection, item, authorizationUrl]);
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleSave = () => { save(); };
const handleChange = (key, value) => {
@@ -71,6 +60,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
tokenSource,
[key]: value
}
})
@@ -184,6 +174,25 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-type">
<label className="block min-w-[140px]">Token Source</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
]}
selectedItemId={tokenSource}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-name">
<label className="block min-w-[140px]">Token ID</label>
<div className="oauth2-input-wrapper flex-1">
@@ -203,26 +212,19 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
<div className="flex items-center gap-4 w-full" key="input-token-placement">
<label className="block min-w-[140px]">Add Token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Headers
<MenuDropdown
items={[
{ id: 'header', label: 'Headers', onClick: () => handleChange('tokenPlacement', 'header') },
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
]}
selectedItemId={tokenPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</MenuDropdown>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useRef, forwardRef } from 'react';
import React from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
@@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import Dropdown from 'components/Dropdown';
import MenuDropdown from 'ui/MenuDropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
@@ -16,8 +16,6 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = get(request, 'auth.oauth2', {});
const { isSensitive } = useDetectSensitiveField(collection);
@@ -36,6 +34,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters
} = oAuth;
@@ -44,24 +43,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
const handleSave = () => { save(); };
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleChange = (key, value) => {
dispatch(
updateAuth({
@@ -84,6 +65,7 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters,
[key]: value
}
@@ -130,26 +112,19 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
<MenuDropdown
items={[
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
]}
selectedItemId={credentialsPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</MenuDropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
@@ -160,6 +135,24 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-type">
<label className="block min-w-[140px]">Token Source</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
]}
selectedItemId={tokenSource}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-name">
<label className="block min-w-[140px]">Token ID</label>
<div className="single-line-editor-wrapper flex-1">
@@ -178,26 +171,19 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
<div className="flex items-center gap-4 w-full" key="input-token-placement">
<label className="block min-w-[140px]">Add token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
<MenuDropdown
items={[
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
]}
selectedItemId={tokenPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</MenuDropdown>
</div>
</div>
{

View File

@@ -1,9 +1,28 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { buildClientSchema, buildSchema } from 'graphql';
import { buildClientSchema, buildSchema, validateSchema } from 'graphql';
import { fetchGqlSchema } from 'utils/network';
import { simpleHash, safeParseJSON } from 'utils/common';
const buildAndValidateSchema = (data) => {
let schema;
if (typeof data === 'object') {
schema = buildClientSchema(data);
} else {
schema = buildSchema(data);
}
// Validate the schema to catch issues like empty object types
// The GraphQL spec requires object types to have at least one field
const validationErrors = validateSchema(schema);
if (validationErrors.length > 0) {
const errorMessages = validationErrors.map((e) => e.message).join('; ');
console.warn('GraphQL schema has validation issues:', errorMessages);
}
return { schema, validationErrors };
};
const schemaHashPrefix = 'bruno.graphqlSchema';
const useGraphqlSchema = (endpoint, environment, request, collection) => {
@@ -19,13 +38,11 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
return null;
}
let parsedData = safeParseJSON(saved);
if (typeof parsedData === 'object') {
return buildClientSchema(parsedData);
} else {
return buildSchema(parsedData);
}
} catch {
localStorage.setItem(localStorageKey, null);
const { schema } = buildAndValidateSchema(parsedData);
return schema;
} catch (err) {
localStorage.removeItem(localStorageKey);
console.warn('Failed to load cached GraphQL schema:', err.message);
return null;
}
});
@@ -72,13 +89,19 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
data = await loadSchemaFromIntrospection();
}
if (data) {
if (typeof data === 'object') {
setSchema(buildClientSchema(data));
} else {
setSchema(buildSchema(data));
}
const { schema, validationErrors } = buildAndValidateSchema(data);
setSchema(schema);
localStorage.setItem(localStorageKey, JSON.stringify(data));
toast.success('GraphQL Schema loaded successfully');
if (validationErrors.length > 0) {
const errorMessages = validationErrors.map((e) => e.message).join('; ');
toast(`Schema validation issues: ${errorMessages}`, {
icon: '⚠️',
duration: 5000
});
} else {
toast.success('GraphQL Schema loaded successfully');
}
}
} catch (err) {
setError(err);

View File

@@ -118,7 +118,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
};
const handleReflection = async (url, isManualRefresh = false) => {
const { methods, error } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh);
const { methods, error, fromCache } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh);
if (error) {
toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`);
@@ -139,7 +139,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
}));
}
if (methods && methods.length > 0) {
if (!fromCache && methods && methods.length > 0) {
toast.success(`Loaded ${methods.length} gRPC methods from reflection`);
}
@@ -161,7 +161,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
};
const handleProtoFileLoad = async (filePath, isManualRefresh = false) => {
const { methods, error } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh);
const { methods, error, fromCache } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh);
if (error) {
console.error('Failed to load gRPC methods:', error);
@@ -174,7 +174,9 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
setGrpcMethods(methods);
setIsReflectionMode(false);
toast.success(`Loaded ${methods.length} gRPC methods from proto file`);
if (!fromCache) {
toast.success(`Loaded ${methods.length} gRPC methods from proto file`);
}
if (methods && methods.length > 0) {
const haveSelectedMethod = selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path);

View File

@@ -24,6 +24,25 @@ const CodeMirror = require('codemirror');
const md = new MD();
const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/;
const createSafeGraphQLLinter = () => {
// Get the original GraphQL lint helper registered by codemirror-graphql
const originalLinter = CodeMirror.helpers?.lint?.graphql?.[0];
return (text, options) => {
try {
if (originalLinter) {
return originalLinter(text, options);
}
return [];
} catch (error) {
// Log the error but don't crash - return empty lint results
// This can happen if the schema has validation issues
console.warn('GraphQL lint error (schema may be invalid):', error.message);
return [];
}
};
};
export default class QueryEditor extends React.Component {
constructor(props) {
super(props);
@@ -57,6 +76,7 @@ export default class QueryEditor extends React.Component {
minFoldSize: 4
},
lint: {
getAnnotations: createSafeGraphQLLinter(),
schema: this.props.schema,
validationRules: this.props.validationRules ?? null,
// linting accepts string or FragmentDefinitionNode[]
@@ -156,8 +176,15 @@ export default class QueryEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
}
}
if (this.props.theme !== prevProps.theme && this.editor) {

View File

@@ -179,6 +179,7 @@ const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect, showCaret
items={menuItems}
placement="bottom-start"
selectedItemId={selectedItemId}
data-testid="method-selector"
>
<TriggerButton method={method} showCaret={showCaret} methodSpanRef={methodSpanRef} />
</MenuDropdown>

View File

@@ -103,7 +103,7 @@ const RequestBodyMode = ({ item, collection }) => {
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer body-mode-selector">
<div className="inline-flex items-center cursor-pointer body-mode-selector" data-testid="request-body-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"

View File

@@ -46,7 +46,7 @@ const RequestBody = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full">
<StyledWrapper className="w-full" data-testid="request-body-editor">
<CodeEditor
collection={collection}
item={item}

View File

@@ -1,9 +1,11 @@
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -15,27 +17,22 @@ const Script = ({ item, collection }) => {
const requestScript = item.draft ? get(item, 'draft.request.script.req') : get(item, 'request.script.req');
const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res');
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
const getDefaultTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevItemUidRef = useRef(item.uid);
const activeTab = scriptPaneTab || getDefaultTab();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different item
useEffect(() => {
if (prevItemUidRef.current !== item.uid) {
prevItemUidRef.current = item.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [item.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
// Small delay to ensure DOM is updated
@@ -76,17 +73,25 @@ const Script = ({ item, collection }) => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
const onScriptTabChange = (tab) => {
dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: tab }));
};
return (
<div className="w-full h-full flex flex-col">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<Tabs value={activeTab} onValueChange={onScriptTabChange}>
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{hasPreRequestScript && <StatusDot />}
{hasPreRequestScript && (
<StatusDot type={item.preRequestScriptErrorMessage ? 'error' : 'default'} />
)}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{hasPostResponseScript && <StatusDot />}
{hasPostResponseScript && (
<StatusDot type={item.postResponseScriptErrorMessage ? 'error' : 'default'} />
)}
</TabsTrigger>
</TabsList>

View File

@@ -58,6 +58,7 @@ const Tags = ({ item, collection }) => {
handleRemoveTag={handleRemove}
tags={tags}
onSave={handleRequestSave}
collectionFormat={collection.format}
/>
</div>
);

View File

@@ -116,6 +116,7 @@ const Settings = ({ item, collection }) => {
label="URL Encoding"
description="Automatically encode query parameters in the URL"
size="medium"
data-testid="encode-url-toggle"
/>
</div>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import ErrorBanner from 'ui/ErrorBanner';
import Button from 'ui/Button';

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import ErrorBanner from 'ui/ErrorBanner';
import Button from 'ui/Button';

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import ErrorBanner from 'ui/ErrorBanner';
import Button from 'ui/Button';

View File

@@ -32,7 +32,7 @@ import WSRequestPane from 'components/RequestPane/WSRequestPane';
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
import ResponseExample from 'components/ResponseExample';
import WorkspaceHome from 'components/WorkspaceHome';
import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview';
import Preferences from 'components/Preferences';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
@@ -43,9 +43,6 @@ const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const RequestTabPanel = () => {
if (typeof window == 'undefined') {
return <div></div>;
}
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -53,6 +50,8 @@ const RequestTabPanel = () => {
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const _collections = useSelector((state) => state.collections.collections);
const preferences = useSelector((state) => state.app.preferences);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
@@ -171,6 +170,10 @@ const RequestTabPanel = () => {
}
}, [isConsoleOpen, isVerticalLayout]);
if (typeof window == 'undefined') {
return <div></div>;
}
if (!activeTabUid || !focusedTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
@@ -183,6 +186,14 @@ const RequestTabPanel = () => {
return <Preferences />;
}
if (focusedTab.type === 'workspaceOverview') {
return activeWorkspace ? <WorkspaceOverview workspace={activeWorkspace} /> : null;
}
if (focusedTab.type === 'workspaceEnvironments') {
return <GlobalEnvironmentSettings />;
}
if (!focusedTab.uid || !focusedTab.collectionUid) {
return <div className="pb-4 px-4">An error occurred!</div>;
}

View File

@@ -0,0 +1,129 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.collection-switcher {
display: flex;
align-items: center;
gap: 4px;
}
.switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
font-weight: 500;
transition: background-color 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
.switcher-name {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.scratch-collection {
font-weight: 600;
font-size: 15px;
}
}
.tab-count {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 10px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
min-width: 18px;
text-align: center;
}
.chevron {
opacity: 0.6;
flex-shrink: 0;
}
}
.workspace-actions-trigger {
cursor: pointer;
opacity: 0.6;
padding: 4px;
border-radius: 4px;
transition: opacity 0.15s ease, background-color 0.15s ease;
&:hover {
opacity: 1;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
.workspace-rename-container {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
}
.workspace-name-input {
font-size: 14px;
font-weight: 500;
padding: 2px 6px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 3px;
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
outline: none;
min-width: 150px;
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
}
.inline-actions {
display: flex;
align-items: center;
gap: 2px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 3px;
cursor: pointer;
background: transparent;
color: ${(props) => props.theme.text};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&.save {
color: ${(props) => props.theme.colors.text.green};
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
}
}
.workspace-error {
font-size: 12px;
color: ${(props) => props.theme.colors.text.danger};
margin-left: 8px;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,467 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconCategory,
IconBox,
IconChevronDown,
IconRun,
IconEye,
IconSettings,
IconDots,
IconEdit,
IconX,
IconCheck,
IconFolder,
IconUpload
} from '@tabler/icons';
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
import Dropdown from 'components/Dropdown';
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import ToolHint from 'components/ToolHint';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import ActionIcon from 'ui/ActionIcon';
import { getRevealInFolderLabel } from 'utils/common/platform';
import classNames from 'classnames';
import StyledWrapper from './StyledWrapper';
const CollectionHeader = ({ collection, isScratchCollection }) => {
const dispatch = useDispatch();
const workspaces = useSelector((state) => state.workspaces.workspaces);
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
// Get the current active workspace
const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
// Workspace rename state
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
const [workspaceNameError, setWorkspaceNameError] = useState('');
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
const switcherRef = useRef();
const workspaceActionsRef = useRef();
const workspaceNameInputRef = useRef(null);
const workspaceRenameContainerRef = useRef(null);
const onSwitcherCreate = (ref) => (switcherRef.current = ref);
const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref);
// Auto-enter rename mode when workspace is newly created
useEffect(() => {
if (isScratchCollection && currentWorkspace?.isNewlyCreated) {
dispatch(updateWorkspace({ uid: currentWorkspace.uid, isNewlyCreated: false }));
setIsRenamingWorkspace(true);
setWorkspaceNameInput(currentWorkspace.name || '');
setWorkspaceNameError('');
const timer = setTimeout(() => {
workspaceNameInputRef.current?.focus();
workspaceNameInputRef.current?.select();
}, 50);
return () => clearTimeout(timer);
}
}, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]);
const handleCancelWorkspaceRename = useCallback(() => {
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
}, []);
useEffect(() => {
if (!isRenamingWorkspace) return;
const handleClickOutside = (event) => {
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
handleCancelWorkspaceRename();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isRenamingWorkspace, handleCancelWorkspaceRename]);
if (!collection) {
return null;
}
// Get mounted collections for the current workspace (excluding scratch collections)
const mountedCollections = collections.filter((c) => {
if (c.mountStatus !== 'mounted') return false;
const isScratch = workspaces.some((w) => w.scratchCollectionUid === c.uid);
if (isScratch) return false;
const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || [];
return workspaceCollectionPaths.some((wcPath) => c.pathname === wcPath);
});
// Count tabs for the current collection
const tabCount = tabs.filter((t) => t.collectionUid === collection.uid).length;
// Get tab count for a given collection uid
const getTabCount = (collectionUid) => tabs.filter((t) => t.collectionUid === collectionUid).length;
// Get tab count for workspace (scratch collection)
const workspaceTabCount = currentWorkspace?.scratchCollectionUid
? getTabCount(currentWorkspace.scratchCollectionUid)
: 0;
// Display name and icon based on context
const displayName = isScratchCollection
? (currentWorkspace?.name || 'Untitled Workspace')
: (collection.name || 'Untitled Collection');
const DisplayIcon = isScratchCollection ? IconCategory : IconBox;
// Switcher handlers
const handleSwitchToWorkspace = (workspaceUid) => {
switcherRef.current?.hide();
if (workspaceUid) {
dispatch(switchWorkspace(workspaceUid));
}
};
const handleSwitchToCollection = (targetCollection) => {
switcherRef.current?.hide();
if (!targetCollection?.uid) return;
const existingTab = tabs.find((t) => t.collectionUid === targetCollection.uid);
if (existingTab) {
dispatch(focusTab({ uid: existingTab.uid }));
} else {
dispatch(
addTab({
uid: targetCollection.uid,
collectionUid: targetCollection.uid,
type: 'collection-settings'
})
);
}
};
// Collection action handlers
const handleRun = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-runner'
})
);
};
const viewVariables = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'variables'
})
);
};
const viewCollectionSettings = () => {
dispatch(
addTab({
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
};
// Workspace action handlers (only used when isScratchCollection is true)
const handleRenameWorkspaceClick = () => {
workspaceActionsRef.current?.hide();
setIsRenamingWorkspace(true);
setWorkspaceNameInput(currentWorkspace?.name || '');
setWorkspaceNameError('');
setTimeout(() => {
workspaceNameInputRef.current?.focus();
workspaceNameInputRef.current?.select();
}, 50);
};
const handleCloseWorkspaceClick = () => {
workspaceActionsRef.current?.hide();
if (currentWorkspace?.type === 'default') {
toast.error('Cannot close the default workspace');
return;
}
setCloseWorkspaceModalOpen(true);
};
const handleShowInFolder = () => {
workspaceActionsRef.current?.hide();
const pathname = currentWorkspace?.pathname;
if (pathname) {
dispatch(showInFolder(pathname)).catch(() => {
toast.error('Error opening the folder');
});
}
};
const handleExportWorkspace = () => {
workspaceActionsRef.current?.hide();
const uid = currentWorkspace?.uid;
if (!uid) return;
dispatch(exportWorkspaceAction(uid))
.then((result) => {
if (!result?.canceled) {
toast.success('Workspace exported successfully');
}
})
.catch((error) => {
toast.error(error?.message || 'Error exporting workspace');
});
};
const validateWorkspaceName = (name) => {
const trimmed = name?.trim();
if (!trimmed) {
return 'Name is required';
}
if (trimmed.length > 255) {
return 'Must be 255 characters or less';
}
return null;
};
const handleSaveWorkspaceRename = () => {
const error = validateWorkspaceName(workspaceNameInput);
if (error) {
setWorkspaceNameError(error);
return;
}
const uid = currentWorkspace?.uid;
if (!uid) return;
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
.then(() => {
toast.success('Workspace renamed!');
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while renaming the workspace');
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
});
};
const handleWorkspaceNameChange = (e) => {
setWorkspaceNameInput(e.target.value);
if (workspaceNameError) {
setWorkspaceNameError('');
}
};
const handleWorkspaceNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveWorkspaceRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelWorkspaceRename();
}
};
// Check if workspace actions should be shown
const showWorkspaceActions = isScratchCollection
&& currentWorkspace
&& currentWorkspace.type !== 'default'
&& !isRenamingWorkspace;
return (
<StyledWrapper>
{closeWorkspaceModalOpen && currentWorkspace?.uid && (
<CloseWorkspace
workspaceUid={currentWorkspace.uid}
onClose={() => setCloseWorkspaceModalOpen(false)}
/>
)}
<div className="flex items-center justify-between gap-2 py-2 px-4">
{/* Left side: Switcher dropdown or rename input */}
<div className="collection-switcher">
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<DisplayIcon size={18} strokeWidth={1.5} />
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
{workspaceNameError && (
<span className="workspace-error">{workspaceNameError}</span>
)}
</div>
) : (
<Dropdown
placement="bottom-start"
onCreate={onSwitcherCreate}
appendTo={() => document.body}
icon={(
<button className="switcher-trigger">
<DisplayIcon size={18} strokeWidth={1.5} />
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
</button>
)}
>
{/* Workspace section */}
{currentWorkspace && (
<>
<div className="label-item">Workspace</div>
<div
className={classNames('dropdown-item', {
'dropdown-item-active': isScratchCollection
})}
onClick={() => handleSwitchToWorkspace(currentWorkspace.uid)}
>
<div className="dropdown-icon">
<IconCategory size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">
{currentWorkspace.name || 'Untitled Workspace'}
</span>
{workspaceTabCount > 0 && (
<span className="dropdown-tab-count">{workspaceTabCount}</span>
)}
</div>
</>
)}
{/* Collections section */}
{mountedCollections.length > 0 && (
<>
<div className="dropdown-separator" />
<div className="label-item">Collections</div>
{mountedCollections.map((col) => {
const colTabCount = getTabCount(col.uid);
return (
<div
key={col.uid}
className={classNames('dropdown-item', {
'dropdown-item-active': !isScratchCollection && collection.uid === col.uid
})}
onClick={() => handleSwitchToCollection(col)}
>
<div className="dropdown-icon">
<IconBox size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">{col.name || 'Untitled Collection'}</span>
{colTabCount > 0 && (
<span className="dropdown-tab-count">{colTabCount}</span>
)}
</div>
);
})}
</>
)}
</Dropdown>
)}
{/* Workspace actions dropdown */}
{showWorkspaceActions && (
<Dropdown
placement="bottom-start"
onCreate={onWorkspaceActionsCreate}
appendTo={() => document.body}
icon={<IconDots size={18} strokeWidth={1.5} className="workspace-actions-trigger" />}
>
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
<div className="dropdown-icon">
<IconEdit size={16} strokeWidth={1.5} />
</div>
<span>Rename</span>
</div>
<div className="dropdown-item" onClick={handleShowInFolder}>
<div className="dropdown-icon">
<IconFolder size={16} strokeWidth={1.5} />
</div>
<span>{getRevealInFolderLabel()}</span>
</div>
<div className="dropdown-item" onClick={handleExportWorkspace}>
<div className="dropdown-icon">
<IconUpload size={16} strokeWidth={1.5} />
</div>
<span>Export</span>
</div>
<div className="dropdown-item" onClick={handleCloseWorkspaceClick}>
<div className="dropdown-icon">
<IconX size={16} strokeWidth={1.5} />
</div>
<span>Close</span>
</div>
</Dropdown>
)}
</div>
{/* Right side: Actions (only for regular collections) */}
{!isScratchCollection && (
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<JsSandboxMode collection={collection} />
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>
</div>
)}
</div>
</StyledWrapper>
);
};
export default CollectionHeader;

View File

@@ -1,5 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div``;
export default StyledWrapper;

View File

@@ -1,83 +0,0 @@
import React from 'react';
import { uuid } from 'utils/common';
import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import ActionIcon from 'ui/ActionIcon';
const CollectionToolBar = ({ collection }) => {
const dispatch = useDispatch();
if (!collection) {
return null;
}
const handleRun = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-runner'
})
);
};
const viewVariables = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'variables'
})
);
};
const viewCollectionSettings = () => {
dispatch(
addTab({
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
};
return (
<StyledWrapper>
<div className="flex items-center justify-between gap-2 py-2 px-4">
<button className="flex items-center cursor-pointer hover:underline bg-transparent border-none p-0 text-inherit" onClick={viewCollectionSettings}>
<IconBox size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
</button>
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
{/* ToolHint is present within the JsSandboxMode component */}
<JsSandboxMode collection={collection} />
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>
</div>
</div>
</StyledWrapper>
);
};
export default CollectionToolBar;

View File

@@ -1,8 +1,8 @@
import React, { useState, useRef, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { hasExampleChanges, findItemInCollection } from 'utils/collections';
import ExampleIcon from 'components/Icons/ExampleIcon';
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import GradientCloseButton from './GradientCloseButton';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld } from '@tabler/icons';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld, IconHome } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
const getTabInfo = (type, tabName) => {
@@ -69,6 +69,22 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
</>
);
}
case 'workspaceOverview': {
return (
<>
<IconHome size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">Overview</span>
</>
);
}
case 'workspaceEnvironments': {
return (
<>
<IconWorld size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">Environments</span>
</>
);
}
}
};
@@ -80,7 +96,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
>
{getTabInfo(type, tabName)}
</div>
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
{handleCloseClick && <GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />}
</>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import get from 'lodash/get';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
@@ -36,6 +36,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false);
const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeTab = tabs.find((t) => t.uid === activeTabUid);
const menuDropdownRef = useRef();
const item = findItemInCollection(collection, tab.uid);
@@ -86,6 +90,62 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
}, [item, item?.name, method, setHasOverflow]);
useEffect(() => {
const handleCloseTabFromHotkeys = () => {
if (!activeTabUid || !activeTab) return;
// Only the active tab component should handle this
if (tab.uid !== activeTabUid) return;
// Always compute item for the active tab
const activeItem = findItemInCollection(collection, activeTabUid);
switch (activeTab.type) {
case 'request':
if (activeItem && hasRequestChanges(activeItem)) {
console.log('Item have changes');
setShowConfirmClose(true);
} else {
console.log('Item dont have changes');
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
case 'collection-settings':
if (collection?.draft) {
setShowConfirmCollectionClose(true);
} else {
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
case 'folder-settings': {
const folderItem = findItemInCollection(collection, activeTab.folderUid || tab.folderUid);
if (folderItem?.draft) {
setShowConfirmFolderClose(true);
} else {
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
}
case 'environment-settings':
if (collection?.environmentsDraft) {
setShowConfirmEnvironmentClose(true);
} else {
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
default:
break;
}
};
window.addEventListener('close-active-tab', handleCloseTabFromHotkeys);
return () => window.removeEventListener('close-active-tab', handleCloseTabFromHotkeys);
}, [dispatch, activeTab, activeTabUid, tab.uid, collection]);
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();
@@ -172,7 +232,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
setShowConfirmGlobalEnvironmentClose(true);
};
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences'].includes(tab.type)) {
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences', 'workspaceOverview', 'workspaceEnvironments'].includes(tab.type)) {
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
@@ -236,6 +296,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{showConfirmEnvironmentClose && tab.type === 'environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={false}
isDotEnv={collection.environmentsDraft?.environmentUid?.startsWith('dotenv:')}
onCancel={() => setShowConfirmEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
@@ -244,7 +305,25 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
onSaveAndClose={() => {
const draft = collection.environmentsDraft;
if (draft?.environmentUid && draft?.variables) {
if (draft?.environmentUid?.startsWith('dotenv:')) {
const onSuccess = () => {
cleanup();
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmEnvironmentClose(false);
};
const onFailed = () => {
cleanup();
setShowConfirmEnvironmentClose(false);
};
const cleanup = () => {
window.removeEventListener('dotenv-save-complete', onSuccess);
window.removeEventListener('dotenv-save-failed', onFailed);
};
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
.then(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
@@ -263,6 +342,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{showConfirmGlobalEnvironmentClose && tab.type === 'global-environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={true}
isDotEnv={globalEnvironmentDraft?.environmentUid?.startsWith('dotenv:')}
onCancel={() => setShowConfirmGlobalEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearGlobalEnvironmentDraft());
@@ -271,7 +351,25 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
onSaveAndClose={() => {
const draft = globalEnvironmentDraft;
if (draft?.environmentUid && draft?.variables) {
if (draft?.environmentUid?.startsWith('dotenv:')) {
const onSuccess = () => {
cleanup();
dispatch(clearGlobalEnvironmentDraft());
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmGlobalEnvironmentClose(false);
};
const onFailed = () => {
cleanup();
setShowConfirmGlobalEnvironmentClose(false);
};
const cleanup = () => {
window.removeEventListener('dotenv-save-complete', onSuccess);
window.removeEventListener('dotenv-save-failed', onFailed);
};
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
.then(() => {
dispatch(clearGlobalEnvironmentDraft());
@@ -297,6 +395,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
<SpecialTab handleCloseClick={handleCloseEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} />
) : tab.type === 'global-environment-settings' ? (
<SpecialTab handleCloseClick={handleCloseGlobalEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} />
) : tab.type === 'workspaceOverview' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : tab.type === 'workspaceEnvironments' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}
@@ -474,19 +576,42 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
} catch (err) { }
}
async function handleCloseMultipleTabs(tabs) {
const tabUidsToClose = [];
for (const tab of tabs) {
const item = findItemInCollection(collection, tab.uid);
if (item && hasRequestChanges(item)) {
try {
await dispatch(saveRequest(item.uid, collection.uid, true));
} catch (err) {
continue;
}
}
if (tab?.uid) {
tabUidsToClose.push(tab.uid);
}
}
if (tabUidsToClose.length > 0) {
dispatch(closeTabs({ tabUids: tabUidsToClose }));
}
}
async function handleCloseOtherTabs() {
const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex);
await Promise.all(otherTabs.map((tab) => handleCloseTab(tab.uid)));
await handleCloseMultipleTabs(otherTabs);
}
async function handleCloseTabsToTheLeft() {
const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
await Promise.all(leftTabs.map((tab) => handleCloseTab(tab.uid)));
await handleCloseMultipleTabs(leftTabs);
}
async function handleCloseTabsToTheRight() {
const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
await Promise.all(rightTabs.map((tab) => handleCloseTab(tab.uid)));
await handleCloseMultipleTabs(rightTabs);
}
function handleCloseSavedTabs() {
@@ -497,7 +622,7 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
}
async function handleCloseAllTabs() {
await Promise.all(collectionRequestTabs.map((tab) => handleCloseTab(tab.uid)));
await handleCloseMultipleTabs(collectionRequestTabs);
}
const menuItems = useMemo(() => [

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import find from 'lodash/find';
import filter from 'lodash/filter';
import classnames from 'classnames';
@@ -6,7 +6,7 @@ import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import CollectionToolBar from './CollectionToolBar';
import CollectionHeader from './CollectionHeader';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
@@ -27,6 +27,7 @@ const RequestTabs = () => {
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
const workspaces = useSelector((state) => state.workspaces.workspaces);
const createSetHasOverflow = useCallback((tabUid) => {
return (hasOverflow) => {
@@ -46,6 +47,10 @@ const RequestTabs = () => {
const activeCollection = find(collections, (c) => c?.uid === activeTab?.collectionUid);
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid);
const isScratchCollection = useMemo(() => {
return activeCollection ? workspaces.some((w) => w.scratchCollectionUid === activeCollection.uid) : false;
}, [workspaces, activeCollection]);
useEffect(() => {
if (!activeTabUid || !activeTab) return;
@@ -110,7 +115,12 @@ const RequestTabs = () => {
)}
{collectionRequestTabs && collectionRequestTabs.length ? (
<>
{activeCollection && <CollectionToolBar collection={activeCollection} />}
{activeCollection && (
<CollectionHeader
collection={activeCollection}
isScratchCollection={isScratchCollection}
/>
)}
<div className="flex items-center gap-2 pl-2" ref={collectionTabsRef}>
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>
<ActionIcon size="lg" onClick={leftSlide} aria-label="Left Chevron" style={{ marginBottom: '3px' }}>

View File

@@ -4,7 +4,7 @@ import { IconBookmark } from '@tabler/icons';
import { addResponseExample } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { uuid } from 'utils/common';
import { uuid, formatResponse } from 'utils/common';
import toast from 'react-hot-toast';
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
import { getBodyType } from 'utils/responseBodyProcessor';
@@ -83,7 +83,7 @@ const ResponseBookmark = forwardRef(({ item, collection, responseSize, children
const contentType = contentTypeHeader?.value?.toLowerCase() || '';
const bodyType = getBodyType(contentType);
const content = response.data;
const content = formatResponse(response.data, response.dataBuffer, bodyType);
const exampleData = {
name: name,

View File

@@ -9,7 +9,7 @@ import ActionIcon from 'ui/ActionIcon/index';
const ResponseDownload = forwardRef(({ item, children }, ref) => {
const { ipcRenderer } = window;
const response = item.response || {};
const isDisabled = !response.dataBuffer ? true : false;
const isDisabled = !response.dataBuffer || response.stream?.running;
const elementRef = useRef(null);
useImperativeHandle(ref, () => ({

View File

@@ -164,7 +164,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
if (!items?.length) return;
items.forEach((item) => {
if (isItemARequest(item) && !item.partial) {
if (isItemARequest(item) && !item.partial && !item.isTransient) {
const relativePath = path.relative(collection.pathname, path.dirname(item.pathname));
const folderPath = relativePath !== '.' ? relativePath : '';

View File

@@ -0,0 +1,39 @@
import React, { useMemo, useCallback, memo } from 'react';
import { useSelector } from 'react-redux';
import { IconDatabase, IconLoader2 } from '@tabler/icons';
import { areItemsLoading } from 'utils/collections';
const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => {
const collection = useSelector((state) =>
state.collections.collections.find((c) => c.uid === collectionUid || c.pathname === collectionPath)
);
const isLoading = useMemo(() => {
const isMounted = collection?.mountStatus === 'mounted';
const fullyLoaded = isMounted && !areItemsLoading(collection);
return isSelected && !fullyLoaded;
}, [collection, isSelected]);
const handleClick = useCallback(() => {
if (!isLoading) {
onSelect();
}
}, [isLoading, onSelect]);
return (
<li
className={`collection-item ${isLoading ? 'mounting' : ''} ${isSelected ? 'selected' : ''}`}
onClick={handleClick}
>
<div className="collection-item-content">
<IconDatabase size={16} strokeWidth={1.5} />
<span className="collection-item-name">{collectionName}</span>
</div>
{isLoading && (
<IconLoader2 size={16} strokeWidth={1.5} className="animate-spin" />
)}
</li>
);
});
export default CollectionListItem;

View File

@@ -3,7 +3,7 @@ import { useSelector, useDispatch } from 'react-redux';
import { pluralizeWord } from 'utils/common';
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
import { clearAllSaveTransientRequestModals } from 'providers/ReduxStore/slices/collections';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import Button from 'ui/Button';

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { IconChevronRight } from '@tabler/icons';
const FolderBreadcrumbs = ({
collectionName,
breadcrumbs,
isAtRoot,
onNavigateToRoot,
onNavigateToBreadcrumb
}) => {
return (
<>
<span
className={!isAtRoot ? 'collection-name-breadcrumb' : ''}
onClick={!isAtRoot ? onNavigateToRoot : undefined}
>
{collectionName}
</span>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
onClick={(e) => {
e.stopPropagation();
onNavigateToBreadcrumb(index);
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</>
);
};
export default FolderBreadcrumbs;

View File

@@ -127,11 +127,84 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
.collection-list {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
max-height: 320px;
overflow-y: auto;
background-color: ${(props) => props.theme.modal.body.bg};
padding: 8px 8px;
}
.collection-list-items {
display: flex;
flex-direction: column;
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
border-radius: ${(props) => props.theme.border.radius.sm};
}
.collection-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
transition: background-color 0.15s ease;
color: ${(props) => props.theme.text};
border-radius: ${(props) => props.theme.border.radius.sm};
user-select: none;
border: 1px solid ${(props) => props.theme.border.border1};
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
border-color: ${(props) => props.theme.colors.text.muted};
}
}
.collection-item-content {
display: flex;
align-items: center;
gap: 10px;
}
.collection-item-name {
color: ${(props) => props.theme.text};
font-weight: 500;
}
.collection-empty-state {
padding: 20px 16px;
text-align: center;
font-size: 14px;
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.icon-success {
color: ${(props) => props.theme.colors.success};
}
.custom-modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0px;
padding: 16px 0px 0px 0px;
background-color: ${(props) => props.theme.modal.body.bg};
border-top: 1px solid ${(props) => props.theme.border.border0};
border-bottom-left-radius: ${(props) => props.theme.border.radius.base};
@@ -163,30 +236,17 @@ const StyledWrapper = styled.div`
padding-top: 12px;
}
.new-folder-content {
.new-folder-header {
display: flex;
align-items: flex-start;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.new-folder-inputs {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.new-folder-name-input-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.new-folder-name-label {
font-size: 12px;
.new-folder-header-label {
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
}
.new-folder-input-row {
@@ -247,13 +307,41 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.new-folder-filesystem-label {
font-size: 12px;
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
}
.filesystem-input-container {
display: flex;
align-items: center;
background: ${(props) => props.theme.requestTabPanel.url.bg};
border-radius: 4px;
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
margin-top: 8px;
}
.filesystem-input-icon {
flex-shrink: 0;
margin-right: 8px;
color: ${(props) => props.theme.colors.text.yellow};
}
.filesystem-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.colors.text.yellow};
font-size: ${(props) => props.theme.font.size.base};
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.new-folder-toggle-filesystem-btn {
@@ -282,6 +370,98 @@ const StyledWrapper = styled.div`
font-size: 12px;
margin-top: 4px;
}
/* New Collection Input Styles */
.new-collection-item {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border-top: 1px solid ${(props) => props.theme.border.border1};
margin-top: 4px;
&:first-child {
border-top: none;
margin-top: 0;
}
}
.new-collection-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.new-collection-label {
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.text};
}
.new-collection-input {
width: 100%;
padding: 8px 10px;
border-radius: ${(props) => props.theme.border.radius.sm};
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
font-size: 14px;
transition: border-color ease-in-out 0.1s;
&:focus {
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
outline: none !important;
}
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&.cursor-pointer {
cursor: pointer;
}
}
.new-collection-location-row {
display: flex;
align-items: center;
gap: 8px;
}
.new-collection-select {
width: 100%;
padding: 8px 10px;
padding-right: 28px;
border-radius: ${(props) => props.theme.border.radius.sm};
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
font-size: 14px;
cursor: pointer;
transition: border-color ease-in-out 0.1s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
&:focus {
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
outline: none !important;
}
}
.new-collection-actions-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.collection-empty-state-subtitle {
font-size: 12px;
margin-top: 4px;
opacity: 0.8;
}
`;
export default StyledWrapper;

View File

@@ -1,21 +1,29 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Modal from 'components/Modal';
import SearchInput from 'components/SearchInput';
import Button from 'ui/Button';
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons';
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff, IconEdit, IconArrowBackUp } from '@tabler/icons';
import PathDisplay from 'components/PathDisplay/index';
import Help from 'components/Help';
import filter from 'lodash/filter';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import CollectionListItem from './CollectionListItem';
import FolderBreadcrumbs from './FolderBreadcrumbs';
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { removeSaveTransientRequestModal } from 'providers/ReduxStore/slices/collections';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { newFolder, closeTabs, mountCollection, createCollection, browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import { resolveRequestFilename } from 'utils/common/platform';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
import path from 'utils/common/path';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } from 'utils/collections';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import { itemSchema } from '@usebruno/schema';
import { uuid } from 'utils/common';
import { formatIpcError } from 'utils/common/error';
import get from 'lodash/get';
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
const dispatch = useDispatch();
@@ -28,12 +36,32 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const item = itemProp;
const collection = collectionProp;
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const allCollections = useSelector((state) => state.collections.collections);
const isScratchCollection = activeWorkspace?.scratchCollectionUid === collection?.uid;
const preferences = useSelector((state) => state.app.preferences);
const isDefaultWorkspace = activeWorkspace?.type === 'default';
const defaultCollectionLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const availableCollections = useMemo(() => {
if (!isScratchCollection || !activeWorkspace) return [];
return (activeWorkspace.collections || []).map((wc) => {
const fullCollection = allCollections.find((c) => c.pathname === wc.path);
// Use stable deterministic UID based on path to avoid duplicate Redux entries
const stableUid = wc.path ? `pending-${wc.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuid();
return fullCollection || { ...wc, uid: stableUid, mountStatus: 'unmounted' };
}).filter((c) => !workspaces.some((w) => w.scratchCollectionUid === c.uid));
}, [isScratchCollection, activeWorkspace, allCollections, workspaces]);
const handleClose = () => {
if (onClose) {
onClose();
return;
}
// Remove from Redux array
dispatch(removeSaveTransientRequestModal({ itemUid: item.uid }));
};
const [requestName, setRequestName] = useState(item?.name || '');
@@ -42,7 +70,29 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const [newFolderName, setNewFolderName] = useState('');
const [newFolderDirectoryName, setNewFolderDirectoryName] = useState('');
const [showFilesystemName, setShowFilesystemName] = useState(false);
const newFolderInputRef = useRef(null);
const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false);
const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null);
// State for new collection creation
const [newCollection, setNewCollection] = useState({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
const [selectedTargetCollectionPath, setSelectedTargetCollectionPath] = useState(null);
const [isSelectingCollection, setIsSelectingCollection] = useState(isScratchCollection);
const folderTreeCollectionUid = selectedTargetCollectionPath
? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)?.uid
: collection?.uid;
const selectedTargetCollection = selectedTargetCollectionPath
? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)
: null;
useEffect(() => {
const isMounted = selectedTargetCollection?.mountStatus === 'mounted';
const isFullyLoaded = isMounted && !areItemsLoading(selectedTargetCollection);
if (selectedTargetCollectionPath && isFullyLoaded) {
setIsSelectingCollection(false);
}
}, [selectedTargetCollectionPath, selectedTargetCollection]);
const {
currentFolders,
@@ -55,27 +105,39 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
getCurrentSelectedFolder,
reset,
isAtRoot
} = useCollectionFolderTree(collection?.uid);
} = useCollectionFolderTree(folderTreeCollectionUid);
const resetForm = () => {
setRequestName(item.name || '');
const resetForm = useCallback(() => {
setRequestName(item?.name || '');
setSearchText('');
reset();
setShowNewFolderInput(false);
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
};
setIsEditingFolderFilename(false);
setPendingFolderNavigation(null);
setSelectedTargetCollectionPath(null);
setIsSelectingCollection(isScratchCollection);
// Reset new collection state
setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
}, [item?.name, isScratchCollection, reset]);
useEffect(() => {
isOpen && item && resetForm();
}, [isOpen, item]);
useEffect(() => {
if (showNewFolderInput && newFolderInputRef.current) {
newFolderInputRef.current.focus();
if (isOpen && item) {
resetForm();
}
}, [showNewFolderInput]);
}, [isOpen, item, resetForm]);
useEffect(() => {
if (pendingFolderNavigation) {
const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation);
if (newFolder) {
navigateIntoFolder(newFolder.uid);
setPendingFolderNavigation(null);
}
}
}, [currentFolders, pendingFolderNavigation, navigateIntoFolder]);
const filteredFolders = useMemo(() => {
if (!searchText.trim()) {
@@ -90,16 +152,41 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
handleClose();
};
const handleSelectCollection = useCallback((selectedCollection) => {
const collectionPath = selectedCollection.path || selectedCollection.pathname;
const isMounted = selectedCollection.mountStatus === 'mounted';
const isFullyLoaded = isMounted && !areItemsLoading(selectedCollection);
setSelectedTargetCollectionPath(collectionPath);
if (isFullyLoaded) {
setIsSelectingCollection(false);
return;
}
if (!isMounted && selectedCollection.mountStatus !== 'mounting') {
dispatch(
mountCollection({
collectionUid: selectedCollection.uid || uuid(),
collectionPathname: collectionPath,
brunoConfig: selectedCollection.brunoConfig
})
);
}
}, [dispatch]);
const handleConfirm = async () => {
if (!item || !collection || !latestItem) {
return;
}
const targetCollection = selectedTargetCollection || collection;
try {
const { ipcRenderer } = window;
const selectedFolder = getCurrentSelectedFolder();
const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname;
const targetDirname = selectedFolder ? selectedFolder.pathname : targetCollection.pathname;
const trimmedName = requestName.trim();
if (!trimmedName || trimmedName.length === 0) {
@@ -107,6 +194,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
return;
}
if (!validateName(trimmedName)) {
toast.error(validateNameError(trimmedName));
return;
}
const sanitizedFilename = sanitizeName(trimmedName);
const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem };
@@ -116,23 +208,32 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
await itemSchema.validate(transformedItem);
const format = collection.format || DEFAULT_COLLECTION_FORMAT;
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
const targetFormat = targetCollection.format || DEFAULT_COLLECTION_FORMAT;
const sourceFormat = collection.format || DEFAULT_COLLECTION_FORMAT;
const targetFilename = resolveRequestFilename(sanitizedFilename, targetFormat);
const targetPathname = path.join(targetDirname, targetFilename);
await ipcRenderer.invoke('renderer:save-transient-request', {
sourcePathname: item.pathname,
targetDirname,
targetFilename,
request: transformedItem,
format
format: targetFormat,
sourceFormat
});
dispatch(
closeTabs({
tabUids: [item.uid]
insertTaskIntoQueue({
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid: targetCollection.uid,
itemPathname: targetPathname,
preview: false
})
);
dispatch(closeTabs({ tabUids: [item.uid] }));
dispatch({
type: 'collections/deleteItem',
payload: {
@@ -144,7 +245,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
toast.success('Request saved successfully');
handleClose();
} catch (err) {
toast.error(err?.message || 'Failed to save request');
toast.error(formatIpcError(err) || 'Failed to save request');
console.error('Error saving request:', err);
}
};
@@ -154,6 +255,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
};
const handleCancelNewFolder = () => {
@@ -161,26 +263,38 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
};
const handleNewFolderNameChange = (value) => {
setNewFolderName(value);
if (!showFilesystemName) {
if (!isEditingFolderFilename) {
setNewFolderDirectoryName(sanitizeName(value));
}
};
const handleDirectoryNameChange = (value) => {
setNewFolderDirectoryName(value);
};
const handleCreateNewFolder = async () => {
const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim());
const trimmedFolderName = newFolderName.trim();
if (!trimmedFolderName) {
toast.error('Folder name is required');
return;
}
if (!validateName(trimmedFolderName)) {
toast.error(validateNameError(trimmedFolderName));
return;
}
const directoryName = newFolderDirectoryName.trim() || sanitizeName(trimmedFolderName);
const parentFolder = getCurrentParentFolder();
const targetCollectionUid = selectedTargetCollection?.uid || collection?.uid;
try {
await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid));
await dispatch(newFolder(trimmedFolderName, directoryName, targetCollectionUid, parentFolder?.uid));
toast.success('New folder created!');
setPendingFolderNavigation(directoryName);
handleCancelNewFolder();
} catch (err) {
const errorMessage = err?.message || 'An error occurred while adding the folder';
@@ -188,11 +302,58 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
}
};
// New Collection handlers
const handleShowNewCollection = () => {
setNewCollection({ show: true, name: '', location: defaultCollectionLocation, format: DEFAULT_COLLECTION_FORMAT });
};
const handleCancelNewCollection = () => {
setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
};
const handleBrowseCollectionLocation = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
setNewCollection((prev) => ({ ...prev, location: dirPath }));
}
})
.catch(() => {});
};
const handleCreateNewCollection = async () => {
const trimmedName = newCollection.name.trim();
if (!trimmedName) {
toast.error('Collection name is required');
return;
}
if (!validateName(trimmedName)) {
toast.error(validateNameError(trimmedName));
return;
}
if (!newCollection.location) {
toast.error('Location is required');
return;
}
try {
await dispatch(createCollection(trimmedName, sanitizeName(trimmedName), newCollection.location, { format: newCollection.format }));
toast.success('Collection created!');
handleCancelNewCollection();
} catch (err) {
toast.error(err?.message || 'An error occurred while creating the collection');
}
};
const handleFolderClick = (folderUid) => {
navigateIntoFolder(folderUid);
setSearchText('');
};
const handleBreadcrumbNavigate = useCallback((index) => {
navigateToBreadcrumb(index);
setSearchText('');
}, [navigateToBreadcrumb]);
if (!isOpen) {
return null;
}
@@ -201,7 +362,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
<StyledWrapper>
<Modal
size="md"
title="Save Request"
title={isSelectingCollection ? 'Select Collection' : 'Save Request'}
handleCancel={handleCancel}
handleConfirm={handleConfirm}
confirmText="Save"
@@ -223,168 +384,360 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
spellCheck="false"
value={requestName}
onChange={(e) => setRequestName(e.target.value)}
autoFocus={true}
autoFocus={!isSelectingCollection}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="collections-section">
<div className="collections-label">Save to Collections</div>
{collection && (
<div
className={`collection-name ${!isAtRoot ? 'collection-name-clickable' : ''}`}
onClick={!isAtRoot ? navigateToRoot : undefined}
>
<span>{collection.name}</span>
{breadcrumbs.length > 0 && (
<div className="collections-label">
{isSelectingCollection ? 'Select a collection to save to' : 'Save to Collections'}
</div>
{isScratchCollection && (
<div className="collection-name">
<span
className={isSelectingCollection ? '' : 'collection-name-breadcrumb'}
onClick={!isSelectingCollection ? () => {
setIsSelectingCollection(true);
setSelectedTargetCollectionPath(null);
reset();
} : undefined}
>
Collections
</span>
{!isSelectingCollection && (
<>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
onClick={(e) => {
e.stopPropagation();
navigateToBreadcrumb(index);
setSearchText('');
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
</>
)}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</div>
)}
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
{isSelectingCollection ? (
<div className="collection-list">
{availableCollections.length > 0 || newCollection.show ? (
<ul className="collection-list-items">
{availableCollections.map((coll) => {
const collPath = coll.path || coll.pathname;
return (
<CollectionListItem
key={collPath}
collectionUid={coll.uid}
collectionPath={collPath}
collectionName={coll.name}
isSelected={selectedTargetCollectionPath === collPath}
onSelect={() => handleSelectCollection(coll)}
/>
);
})}
{newCollection.show && (
<li className="new-collection-item">
<div className="new-collection-field">
<label className="new-collection-label">
Collection name
</label>
<input
ref={(node) => node?.focus()}
type="text"
className="new-collection-input"
placeholder="Enter collection name"
value={newCollection.name}
onChange={(e) => setNewCollection((prev) => ({ ...prev, name: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewCollection();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewCollection();
}
}}
/>
</div>
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-content">
<IconFolder size={16} strokeWidth={1.5} />
<div className="new-folder-inputs">
<div className="new-folder-name-input-wrapper">
{showFilesystemName && (
<label className="new-folder-name-label">New Folder name (in bruno)</label>
)}
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
handleCancelNewFolder();
}
}}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
</div>
<div className="new-collection-field">
<label className="new-collection-label flex items-center">
Location
<Help width={250} placement="top">
<p>
Bruno stores your collections on your computer's filesystem.
</p>
<p className="mt-2">
Choose the location where you want to store this collection.
</p>
</Help>
</label>
<div className="new-collection-location-row">
<input
type="text"
className="new-collection-input cursor-pointer"
placeholder="Select location"
value={newCollection.location}
readOnly
onClick={handleBrowseCollectionLocation}
/>
<Button
type="button"
variant="outline"
color="secondary"
size="sm"
rounded="sm"
onClick={handleBrowseCollectionLocation}
>
Browse
</Button>
</div>
</div>
<div className="new-collection-field">
<label className="new-collection-label flex items-center">
File Format
<Help width={300} placement="top">
<p>
Choose the file format for storing requests in this collection.
</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
</label>
<select
className="new-collection-select"
value={newCollection.format}
onChange={(e) => setNewCollection((prev) => ({ ...prev, format: e.target.value }))}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
<div className="new-collection-actions-footer">
<Button
type="button"
color="secondary"
variant="ghost"
size="sm"
onClick={handleCancelNewCollection}
>
Cancel
</Button>
<Button
type="button"
color="primary"
size="sm"
onClick={handleCreateNewCollection}
>
Save
</Button>
</div>
</li>
)}
</ul>
) : (
<div className="collection-empty-state">
<p>No collections Yet</p>
<p className="collection-empty-state-subtitle">Collections help you organize your requests. Create your first one to save this request.</p>
</div>
)}
</div>
) : (
<>
{!isScratchCollection && (selectedTargetCollection || collection) && (
<div className="collection-name">
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
</div>
)}
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-header">
<IconFolder size={16} strokeWidth={1.5} />
<label className="new-folder-header-label">
{showFilesystemName ? 'New Folder name (in bruno)' : 'New Folder name'}
</label>
</div>
<div className="new-folder-input-row">
<input
ref={(node) => node?.focus()}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
</div>
</div>
{showFilesystemName && (
<div className="new-folder-filesystem-wrapper">
<label className="new-folder-filesystem-label">Name on filesystem</label>
<input
type="text"
className="new-folder-input"
value={newFolderDirectoryName}
onChange={(e) => handleDirectoryNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
}
}}
/>
<div className="flex items-center justify-between">
<label className="new-folder-filesystem-label flex items-center font-medium">
Folder Name <small className="font-normal text-muted ml-1">(on filesystem)</small>
<Help width={300} placement="top">
<p>
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
</p>
</Help>
</label>
{isEditingFolderFilename ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(true)}
/>
)}
</div>
{isEditingFolderFilename ? (
<div className="relative flex flex-row gap-1 items-center justify-between">
<input
type="text"
className="block textbox mt-2 w-full"
placeholder="Folder Name"
value={newFolderDirectoryName}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={(e) => setNewFolderDirectoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">
<PathDisplay
iconType="folder"
baseName={newFolderDirectoryName}
/>
</div>
)}
</div>
)}
</div>
</div>
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
setIsEditingFolderFilename(false);
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
)}
</div>
</>
)}
</div>
</div>
<div className="custom-modal-footer">
<div className="footer-left">
{!showNewFolderInput && (
{!showNewFolderInput && !isSelectingCollection && (
<Button
type="button"
color="primary"
@@ -395,14 +748,27 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
New Folder
</Button>
)}
{isSelectingCollection && !newCollection.show && (
<Button
type="button"
color="primary"
variant="ghost"
icon={<IconFolder size={16} strokeWidth={1.5} />}
onClick={handleShowNewCollection}
>
New collection
</Button>
)}
</div>
<div className="footer-right">
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
{!isSelectingCollection && (
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
)}
</div>
</div>
</Modal>

View File

@@ -85,8 +85,13 @@ const CreateApiSpec = ({ onClose }) => {
...variables
};
}
// Convert envVariables (keyed by filename) to environments array for multi-server export
const environmentsList = Object.entries(envVariables || {}).map(([envFile, vars]) => ({
name: envFile.replace(/\.(bru|yml)$/, ''),
variables: vars
}));
// Create API spec yaml
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files });
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files, environments: environmentsList });
if (exportedYamlContentData?.content) {
yamlContent = exportedYamlContentData?.content;
}

View File

@@ -0,0 +1,28 @@
import styled from 'styled-components';
import { darken } from 'polished';
const StyledWrapper = styled.div`
.current-group {
background-color: ${(props) => props.theme.background.surface1};
border-radius: 4px;
padding: 0.4rem;
cursor: pointer;
border: 1px solid ${(props) => props.theme.background.surface2};
}
.current-group:hover {
background-color: ${(props) => darken(0.03, props.theme.background.surface1)};
border-color: ${(props) => darken(0.03, props.theme.background.surface2)};
}
/* Fix dropdown positioning */
[data-tippy-root] {
left: 0 !important;
}
.bruno-modal-footer {
padding-top: 0;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,887 @@
import React, { useRef, useEffect, useState, useMemo, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
import { isElectron } from 'utils/common/platform';
import { IconX, IconLoader2, IconCheck, IconCaretDown } from '@tabler/icons';
import InfoTip from 'components/InfoTip/index';
import Help from 'components/Help';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import Dropdown from 'components/Dropdown';
import { postmanToBruno } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
import { wsdlToBruno } from '@usebruno/converters';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
const STATUS = {
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error'
};
const IMPORT_TYPE = {
BULK: 'bulk',
MULTIPLE: 'multiple'
};
const groupingOptions = [
{ value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' },
{ value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' }
];
// Extract collection name from raw data
const getCollectionName = (format, rawData) => {
if (!rawData) return 'Collection';
switch (format) {
case 'openapi':
return rawData.info?.title || 'OpenAPI Collection';
case 'postman':
return rawData.info?.name || rawData.collection?.info?.name || 'Postman Collection';
case 'insomnia':
// For Insomnia v4 format, name is in the workspace resource
if (rawData.resources && Array.isArray(rawData.resources)) {
const workspace = rawData.resources.find((r) => r._type === 'workspace');
if (workspace?.name) {
return workspace.name;
}
}
// Fallback to root name property
return rawData.name || 'Insomnia Collection';
case 'bruno':
return rawData.name || 'Bruno Collection';
case 'wsdl':
return 'WSDL Collection';
default:
return 'Collection';
}
};
// Convert raw data to Bruno collection format
const convertCollection = async (format, rawData, groupingType) => {
let collection;
switch (format) {
case 'openapi':
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType });
break;
case 'wsdl':
collection = await wsdlToBruno(rawData);
break;
case 'postman':
collection = await postmanToBruno(rawData);
break;
case 'insomnia':
collection = convertInsomniaToBruno(rawData);
break;
case 'bruno':
collection = await processBrunoCollection(rawData);
break;
default:
throw new Error('Unknown collection format');
}
return collection;
};
export function normalizeName(name) {
if (typeof name !== 'string') {
return '';
}
return name.trim().toLowerCase();
}
/**
* Generate a unique name by adding "copy" suffix if the name already exists.
* @param {string} baseName - The original name
* @param {function} checkExists - Function that returns true if name exists
* @returns {string} - Unique name with "copy" suffix if needed
*/
export function generateUniqueName(baseName, checkExists) {
const normalizedBase = normalizeName(baseName);
if (!checkExists(normalizedBase)) {
return baseName;
}
let counter = 1;
let uniqueName = `${baseName} copy`;
while (checkExists(normalizeName(uniqueName))) {
counter++;
uniqueName = `${baseName} copy ${counter}`;
}
return uniqueName;
}
export const BulkImportCollectionLocation = ({
onClose,
handleSubmit,
importData
}) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const [status, setStatus] = useState({});
const [errorMessages, setErrorMessages] = useState({});
const [importStarted, setImportStarted] = useState(false);
const [environmentStatus, setEnvironmentStatus] = useState({});
const [showErrorModal, setShowErrorModal] = useState(false);
const [selectedError, setSelectedError] = useState(null);
const [applyToGlobal, setApplyToGlobal] = useState(true);
const [applyToCollection, setApplyToCollection] = useState(false);
const [groupingType, setGroupingType] = useState('tags');
const [collectionFormat, setCollectionFormat] = useState('bru');
const [renamedCollectionNames, setRenamedCollectionNames] = useState({});
const [renamedEnvironmentNames, setRenamedEnvironmentNames] = useState({});
// Extract data based on import type
const importType = importData?.type;
const isBulkImport = importType === IMPORT_TYPE.BULK;
const isMultipleImport = importType === IMPORT_TYPE.MULTIPLE;
// For bulk import (ZIP files)
const importedCollectionFromBulk = isBulkImport ? importData.collection : [];
const importedEnvironmentFromBulk = isBulkImport ? (importData.environment || []) : [];
// For multiple files import
const filesData = isMultipleImport ? importData.filesData : [];
const hasOpenApiSpec = filesData.some((f) => f.type === 'openapi');
// Create unified collection structure for display
const importedCollection = isMultipleImport
? filesData.map((fileData, index) => ({
uid: `file-${index}`,
name: getCollectionName(fileData.type, fileData.data),
_fileData: fileData
}))
: importedCollectionFromBulk;
const importedEnvironment = isBulkImport ? importedEnvironmentFromBulk : [];
const globalEnvironments = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const existingCollections = useSelector((state) => state?.collections?.collections || []);
// Initialize selected items based on import type
const [selectedCollections, setSelectedCollections] = useState(importedCollection.map((col) => col.uid));
const [selectedEnvironments, setSelectedEnvironments] = useState(isBulkImport ? importedEnvironmentFromBulk.map((env) => env.uid) : []);
const allCollectionsSelected = selectedCollections.length === importedCollection.length;
const allEnvironmentsSelected = selectedEnvironments.length === importedEnvironment.length;
// Sort collections to show selected items first, then unselected items
// This helps users see their selections at the top of the list
const sortedCollections = useMemo(() => {
const arr = [...importedCollection];
arr.sort((a, b) => {
const aSelected = selectedCollections.includes(a.uid);
const bSelected = selectedCollections.includes(b.uid);
// Convert boolean to number: true = 1, false = 0
// bSelected - aSelected means: selected items (1) come before unselected (0)
return Number(bSelected) - Number(aSelected);
});
return arr;
}, [importedCollection, selectedCollections]);
// Sort environments to show selected items first, then unselected items
// This helps users see their selections at the top of the list
const sortedEnvironments = useMemo(() => {
const arr = [...importedEnvironment];
arr.sort((a, b) => {
const aSelected = selectedEnvironments.includes(a.uid);
const bSelected = selectedEnvironments.includes(b.uid);
// selected (true) should come before unselected (false)
return Number(bSelected) - Number(aSelected);
});
return arr;
}, [importedEnvironment, selectedEnvironments]);
const importStatus = useMemo(() => {
const selectedSet = new Set(selectedCollections);
const totalSelected = selectedCollections.length;
const failedCount = Object.entries(status).reduce((acc, [uid, s]) => {
return selectedSet.has(uid) && s === STATUS.ERROR ? acc + 1 : acc;
}, 0);
return {
totalSelected,
failedCount
};
}, [status, selectedCollections]);
// Handlers
const handleCollectionToggle = (uid) => {
setSelectedCollections((prev) =>
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
);
};
const handleEnvironmentToggle = (uid) => {
setSelectedEnvironments((prev) =>
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
);
};
const handleSelectAllCollections = (e) => {
setSelectedCollections(e.target.checked ? importedCollection.map((col) => col.uid) : []);
};
const handleSelectAllEnvironments = (e) => {
setSelectedEnvironments(
e.target.checked ? importedEnvironment.map((env) => env.uid) : []
);
};
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const GroupingDropdownIcon = forwardRef((props, ref) => {
const selectedOption = groupingOptions.find((option) => option.value === groupingType);
return (
<div ref={ref} className="flex items-center justify-between w-full current-group" data-testid="grouping-dropdown">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{selectedOption.label}</div>
</div>
<IconCaretDown size={16} className="text-gray-400 ml-[0.25rem]" fill="currentColor" />
</div>
);
});
const formik = useFormik({
enableReinitialize: true,
initialValues: {
collectionLocation: defaultLocation
},
validationSchema: Yup.object({
collectionLocation: Yup.string()
.min(1, 'must be at least 1 character')
.max(500, 'must be 500 characters or less')
.required('Location is required')
}),
onSubmit: async (values) => {
let filteredCollections = [];
const selectedItems = importedCollection.filter((col) => selectedCollections.includes(col.uid));
if (isMultipleImport) {
// Convert selected files to collections at submit time
for (const item of selectedItems) {
try {
const collection = await convertCollection(item._fileData.type, item._fileData.data, groupingType);
if (collection) {
// Preserve the synthetic UID so status tracking, rename tracking,
// and UI rendering all use the same key
collection.uid = item.uid;
filteredCollections.push(collection);
}
} catch (err) {
console.warn(`Failed to convert file ${item._fileData.file.name}:`, err);
}
}
} else if (isBulkImport) {
// For bulk import, use selected collections directly
filteredCollections = selectedItems;
}
const initialStatus = {};
filteredCollections.forEach((col) => {
initialStatus[col.uid] = STATUS.LOADING;
});
setStatus(initialStatus);
setErrorMessages({});
const filteredEnvironments = importedEnvironment.filter((env) =>
selectedEnvironments.includes(env.uid)
);
// Handle duplicate collection names by renaming new ones to a unique "{originalName} N" suffix
const existingCollectionNames = new Set(existingCollections.map((col) => normalizeName(col.name)));
const usedNames = new Set();
const renamedNames = {};
filteredCollections.forEach((collection) => {
const originalName = collection.name;
let finalName = originalName;
let index = 0;
while (existingCollectionNames.has(normalizeName(finalName)) || usedNames.has(normalizeName(finalName))) {
finalName = `${originalName} ${index + 1}`;
index++;
}
collection.name = finalName;
usedNames.add(normalizeName(finalName));
// Store renamed name for summary display
if (finalName !== originalName) {
renamedNames[collection.uid] = finalName;
}
});
setRenamedCollectionNames(renamedNames);
// Process all selected environments and rename duplicates
// Don't use getUniqueEnvironments as it filters out duplicates - we want to rename them instead
const collectionRenamedEnvNames = {};
const globalRenamedEnvNames = {};
if (applyToCollection) {
// add selected environments to each selected collection
// Rename duplicates with "copy" suffix instead of filtering them out
filteredCollections.forEach((collection) => {
const existingNamesSet = new Set((collection.environments || []).map((e) => normalizeName(e?.name)));
const usedNamesInBatch = new Set();
const envsForCollection = filteredEnvironments.map((env) => {
const originalName = env.name;
const normalizedOriginalName = normalizeName(originalName);
// Check if name exists in collection or was already used in this batch
const checkExists = (name) => existingNamesSet.has(name) || usedNamesInBatch.has(name);
const finalName = generateUniqueName(originalName, checkExists);
// Track renamed name for summary display
if (finalName !== originalName) {
collectionRenamedEnvNames[env.uid] = finalName;
}
usedNamesInBatch.add(normalizeName(finalName));
existingNamesSet.add(normalizeName(finalName));
return { ...env, name: finalName };
});
collection.environments = envsForCollection;
});
// Mark all collection environments as success (they're processed with the collection import)
const envStatusUpdate = {};
filteredEnvironments.forEach((env) => {
envStatusUpdate[env.uid] = STATUS.SUCCESS;
});
setEnvironmentStatus((prev) => ({ ...prev, ...envStatusUpdate }));
if (Object.keys(collectionRenamedEnvNames).length > 0) {
setRenamedEnvironmentNames((prev) => ({ ...prev, ...collectionRenamedEnvNames }));
}
}
if (applyToGlobal && filteredEnvironments.length > 0) {
// Pre-compute unique names for all environments to avoid race conditions
const existingGlobalNames = new Set((globalEnvironments || []).map((env) => normalizeName(env?.name)));
const usedNamesInBatch = new Set();
const envsToImport = [];
filteredEnvironments.forEach((environment) => {
const checkExists = (name) => existingGlobalNames.has(name) || usedNamesInBatch.has(name);
const uniqueName = generateUniqueName(environment.name, checkExists);
if (uniqueName !== environment.name) {
globalRenamedEnvNames[environment.uid] = uniqueName;
}
usedNamesInBatch.add(normalizeName(uniqueName));
envsToImport.push({ ...environment, name: uniqueName });
});
if (Object.keys(globalRenamedEnvNames).length > 0) {
setRenamedEnvironmentNames((prev) => ({ ...prev, ...globalRenamedEnvNames }));
}
envsToImport.forEach((envToImport) => {
const originalUid = envToImport.uid;
setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.LOADING }));
dispatch(addGlobalEnvironment(envToImport))
.then(() => setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.SUCCESS })))
.catch((error) => {
setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.ERROR }));
setErrorMessages((prev) => ({ ...prev, [originalUid]: error.message || 'Failed to add environment' }));
});
});
}
setImportStarted(true);
if (filteredCollections.length > 1 || isBulkImport || isMultipleImport) {
dispatch(importCollection(filteredCollections, values.collectionLocation, { format: collectionFormat }))
.catch((err) => {
console.error('Failed to import collections', err);
filteredCollections.forEach((collection) => {
setStatus((prev) => ({ ...prev, [collection.uid]: STATUS.ERROR }));
setErrorMessages((prev) => ({ ...prev, [collection.uid]: err.message || 'Failed to import collection' }));
});
});
} else {
handleSubmit(filteredCollections[0], values.collectionLocation, { format: collectionFormat });
}
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string' && dirPath.length > 0) {
formik.setFieldValue('collectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');
console.error(error);
});
};
useEffect(() => {
if (!isElectron()) {
return () => {};
}
const { ipcRenderer } = window;
const handleImportStatus = (collectionId, status, errorMessage = '') => {
setStatus((prev) => ({ ...prev, [collectionId]: status }));
if (status === STATUS.ERROR) {
setErrorMessages((prev) => ({
...prev,
[collectionId]: errorMessage
}));
}
};
const importingCollectionStarted = ipcRenderer.on(
'main:collection-import-started',
(collectionId) => {
handleImportStatus(collectionId, STATUS.LOADING);
}
);
const importingCollectionCompleted = ipcRenderer.on(
'main:collection-import-ended',
(collectionId) => {
handleImportStatus(collectionId, STATUS.SUCCESS);
}
);
const importingCollectionFailed = ipcRenderer.on(
'main:collection-import-failed',
(collectionId, { message }) => {
handleImportStatus(collectionId, STATUS.ERROR, message);
}
);
const allCollectionsImportCompleted = ipcRenderer.on(
'main:all-collections-import-ended',
(report) => {
toast.success(report?.message);
}
);
return () => {
importingCollectionStarted();
importingCollectionCompleted();
importingCollectionFailed();
allCollectionsImportCompleted();
};
}, []);
const onSubmit = () => {
if (importStarted) {
onClose();
} else {
formik.handleSubmit();
}
};
const handleErrorClick = (error, uid) => {
setSelectedError({ message: error, uid });
setShowErrorModal(true);
};
const ErrorModal = ({ error, onClose }) => (
<Modal
size="sm"
title="Error Details"
handleConfirm={onClose}
handleCancel={onClose}
showCancelButton={false}
disableCloseOnOutsideClick={true}
hideFooter={true}
>
<div className="p-4">
<pre className="whitespace-pre-wrap text-red-600 text-sm">{error}</pre>
</div>
</Modal>
);
return (
<StyledWrapper>
<Modal
size="md"
title="Bulk Import"
confirmText={importStarted ? 'Close' : 'Import'}
confirmDisabled={Boolean(!selectedCollections?.length)}
handleConfirm={onSubmit}
handleCancel={onClose}
showConfirm={true}
disableCloseOnOutsideClick={true}
disableEscapeKey={false}
hideCancel={importStarted}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div className="flex flex-col">
{importStarted ? (
<>
<div className="mb-6">
<div className="flex items-center justify-between relative mb-5 w-full">
<div className="font-semibold">Location</div>
<div className="text-sm border border-slate-600 rounded px-3 py-1.5 ml-4 flex-1">
{formik.values.collectionLocation
|| 'No location selected'}
</div>
</div>
<div className="flex items-center justify-between mb-2">
<div className="font-semibold">
Importing Collections ({importStatus.totalSelected})
</div>
{importStatus.failedCount > 0 && importStatus.totalSelected > 0 && (
<div className="text-sm text-red-500">
({importStatus.failedCount}/{importStatus.totalSelected} failed)
</div>
)}
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{sortedCollections
.filter((collection) =>
selectedCollections.includes(collection.uid)
)
.map((collection) => (
<div
key={collection.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal justify-between"
>
<div className="flex items-center flex-1">
<div className="flex items-center mr-2">
{status[collection.uid] === STATUS.LOADING && (
<IconLoader2
className="animate-spin text-blue-500"
size={16}
strokeWidth={1.5}
/>
)}
{status[collection.uid] === STATUS.SUCCESS && (
<div className="flex items-center text-green-500">
<IconCheck size={16} strokeWidth={1.5} />
</div>
)}
{status[collection.uid] === STATUS.ERROR && (
<div className="flex items-center">
<IconX
className="text-red-500"
size={16}
strokeWidth={1.5}
/>
</div>
)}
</div>
<span>{renamedCollectionNames[collection.uid] || collection.name}</span>
</div>
{status[collection.uid] === STATUS.ERROR && (
<button
onClick={() =>
handleErrorClick(
errorMessages[collection.uid],
collection.uid
)}
className="text-red-500 text-sm hover:underline"
>
See error
</button>
)}
</div>
))}
</div>
</div>
{selectedEnvironments.length > 0 && (
<div className="mb-6">
<div className="font-semibold mb-2">
Importing Environments ({selectedEnvironments.length})
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{sortedEnvironments
.filter((env) => selectedEnvironments.includes(env.uid))
.map((env) => (
<div
key={env.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal justify-between"
>
<div className="flex items-center flex-1">
<div className="flex items-center mr-2">
{!environmentStatus[env.uid] || environmentStatus[env.uid] === STATUS.LOADING ? (
<IconLoader2
className="animate-spin text-blue-500"
size={16}
strokeWidth={1.5}
/>
) : environmentStatus[env.uid] === STATUS.SUCCESS ? (
<div className="flex items-center text-green-500">
<IconCheck size={16} strokeWidth={1.5} />
</div>
) : environmentStatus[env.uid] === STATUS.ERROR ? (
<div className="flex items-center">
<IconX
className="text-red-500"
size={16}
strokeWidth={1.5}
/>
</div>
) : null}
</div>
<span>{renamedEnvironmentNames[env.uid] || env.name}</span>
</div>
{environmentStatus[env.uid] === STATUS.ERROR && (
<button
onClick={() =>
handleErrorClick(
errorMessages[env.uid],
env.uid
)}
className="text-red-500 text-sm hover:underline"
>
See error
</button>
)}
</div>
))}
</div>
</div>
)}
</>
) : (
<>
<div className="mb-6">
<div className="font-semibold mb-2 flex justify-between items-center">
<span>Collections ({importedCollection.length})</span>
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
<input
type="checkbox"
checked={allCollectionsSelected}
onChange={handleSelectAllCollections}
className="mr-2"
/>
Select All
</label>
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2">
{importedCollection.length === 0 && (
<div className="px-4 py-2 text-gray-400 italic">
No collections found
</div>
)}
{sortedCollections.map((collection) => (
<label
key={collection.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer justify-between"
>
<div className="flex items-center flex-1">
<input
type="checkbox"
checked={selectedCollections.includes(collection.uid)}
onChange={() => handleCollectionToggle(collection.uid)}
className="mr-3"
/>
<span>{collection.name}</span>
</div>
</label>
))}
</div>
</div>
{importType === 'bulk' && (
<>
<div className="mb-4">
<div className="font-semibold mb-2 flex justify-between items-center">
<span>Environments ({importedEnvironment.length})</span>
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
<input
type="checkbox"
checked={allEnvironmentsSelected}
onChange={handleSelectAllEnvironments}
className="mr-2"
/>
Select All
</label>
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{importedEnvironment.length === 0 && (
<div className="px-4 py-2 text-gray-400 italic">
No environments found
</div>
)}
{sortedEnvironments.map((env) => (
<label
key={env.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer"
>
<input
type="checkbox"
checked={selectedEnvironments.includes(env.uid)}
onChange={() => handleEnvironmentToggle(env.uid)}
className="mr-3"
/>
<span>{env.name}</span>
</label>
))}
</div>
</div>
<div className="mb-6">
<div className="font-semibold mb-2">
Environment Assignment
</div>
<div className="flex gap-8 mt-2 ml-2">
<label className="flex items-center">
<input
type="checkbox"
checked={applyToGlobal}
onChange={(e) => setApplyToGlobal(e.target.checked)}
className="mr-2"
/>
<span className="ml-2">
Global Environment
<InfoTip
content="Environments will be imported and stored as global, accessible across collections."
infotipId="apply-to-global-infotip"
/>
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={applyToCollection}
onChange={(e) =>
setApplyToCollection(e.target.checked)}
className="mr-2"
/>
<span className="ml-2">
Duplicate Across Collections
<InfoTip
content="Each imported collection will receive its own copy of the environments."
infotipId="apply-to-each-infotip"
/>
</span>
</label>
</div>
</div>
</>
)}
<div className="flex items-start flex-col relative">
<div className="font-semibold mb-2">Location</div>
<input
id="collection-location"
type="text"
placeholder="Select a location to save the collection"
name="collectionLocation"
className="block textbox w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
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 mt-1">
{formik.errors.collectionLocation}
</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
</div>
</div>
<div className="mt-4">
<label htmlFor="format" className="flex items-center font-semibold">
File Format
<Help width="300">
<p>Choose the file format for storing requests in this collection.</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
</label>
<select
id="format"
name="format"
className="block textbox mt-2 w-full"
value={collectionFormat}
onChange={(e) => setCollectionFormat(e.target.value)}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
{isMultipleImport && hasOpenApiSpec && (
<div>
<div className="flex gap-4 items-center">
<div>
<label htmlFor="groupingType" className="block font-semibold">
Folder arrangement
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 mb-2">
Select whether to create folders according to the spec's paths or tags.
</p>
</div>
<div className="relative">
<Dropdown onCreate={onDropdownCreate} icon={<GroupingDropdownIcon />} placement="bottom-start">
{groupingOptions.map((option) => (
<div
key={option.value}
className="dropdown-item"
data-testid={option.testId}
onClick={() => {
dropdownTippyRef?.current?.hide();
setGroupingType(option.value);
}}
>
{option.label}
</div>
))}
</Dropdown>
</div>
</div>
</div>
)}
</>
)}
</div>
</form>
</Modal>
{showErrorModal && (
<ErrorModal
error={selectedError?.message}
onClose={() => setShowErrorModal(false)}
/>
)}
</StyledWrapper>
);
};
export default BulkImportCollectionLocation;

View File

@@ -0,0 +1,30 @@
import { normalizeName, generateUniqueName } from './index';
describe('BulkImportCollectionLocation helpers', () => {
describe('normalizeName', () => {
it('should trim and lowercase names', () => {
expect(normalizeName(' Beta ')).toBe('beta');
expect(normalizeName('TEST')).toBe('test');
expect(normalizeName(null)).toBe('');
});
});
describe('generateUniqueName', () => {
it('should return original name if no conflict', () => {
const checkExists = () => false;
expect(generateUniqueName('Beta', checkExists)).toBe('Beta');
});
it('should add "copy" suffix on first conflict', () => {
const existing = new Set(['beta']);
const checkExists = (name) => existing.has(name);
expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy');
});
it('should increment copy number on multiple conflicts', () => {
const existing = new Set(['beta', 'beta copy']);
const checkExists = (name) => existing.has(name);
expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy 2');
});
});
});

View File

@@ -0,0 +1,18 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.info-box {
background-color: ${(props) => props.theme.background.mantle};
color: ${(props) => props.theme.text};
border: 1px solid ${(props) => props.theme.border.border2};
padding: 10px;
border-radius: 5px;
margin-top: 5px;
width: 400px;
white-space: pre-wrap;
max-height: 150px;
overflow-y: auto;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,372 @@
import React, { useRef, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import {
browseDirectory,
cloneGitRepository,
openMultipleCollections,
scanForBrunoFiles
} from 'providers/ReduxStore/slices/collections/actions';
import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app';
import Modal from 'components/Modal';
import * as path from 'path';
import Portal from 'components/Portal';
import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons';
import { uuid } from 'utils/common/index';
import StyledWrapper from './StyledWrapper';
import { getRepoNameFromUrl } from 'utils/git';
import GitNotFoundModal from 'components/Git/GitNotFoundModal/index';
import get from 'lodash/get';
const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null }) => {
const [collectionPaths, setCollectionPaths] = useState([]);
const [selectedCollectionPaths, setSelectedCollectionPaths] = useState([]);
const [processUid, setProcessUid] = useState(uuid());
const [steps, setSteps] = useState([]);
const [view, setView] = useState('form');
const progressData = useSelector((state) => state.app.gitOperationProgress[processUid]);
const { gitVersion } = useSelector((state) => state.app);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const inputRef = useRef();
const dispatch = useDispatch();
useEffect(() => {
if (progressData) {
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone' && !step?.completed
? { ...step, title: 'Cloning repository', completed: false, info: progressData.progressData }
: step
)
);
}
}, [progressData]);
useEffect(() => {
if (inputRef?.current) {
inputRef.current.focus();
}
}, []);
const cloneInProgress = () => {
setSteps((prev) => [
...prev,
{
step: 'clone',
title: 'Cloning repository',
completed: false
}
]);
};
const cloneFinished = () => {
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone'
? { ...step, title: 'Cloning successful', completed: true, info: '' }
: step
)
);
};
const cloneError = () => {
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone'
? { ...step, title: 'Cloning failed', completed: true, error: true }
: step
)
);
};
const scanInProgress = () => {
setSteps((prev) => [
...prev,
{
step: 'scan',
title: 'Scanning for Bruno files',
completed: false
}
]);
};
const scanFinished = () => {
setSteps((prev) =>
prev.map((step) =>
step.step === 'scan' ? { ...step, title: 'Scan successful', completed: true, info: '' } : step
)
);
};
const formik = useFormik({
enableReinitialize: true,
initialValues: {
repositoryUrl: collectionRepositoryUrl || '',
collectionLocation: defaultLocation
},
validationSchema: Yup.object({
repositoryUrl: Yup.string().required('Repository URL is required'),
collectionLocation: Yup.string().min(1, 'Location is required').required('Location is required')
}),
onSubmit: async (values) => {
try {
setView('progress');
cloneInProgress();
const { repositoryUrl, collectionLocation } = values;
const repoName = getRepoNameFromUrl(repositoryUrl);
const targetPath = path.join(collectionLocation, repoName);
await dispatch(cloneGitRepository({ url: values.repositoryUrl, path: targetPath, processUid }));
cloneFinished();
dispatch(removeGitOperationProgress(processUid));
scanInProgress();
const foundCollectionPaths = await dispatch(scanForBrunoFiles(targetPath));
scanFinished();
setCollectionPaths(foundCollectionPaths);
} catch (err) {
cloneError();
dispatch(removeGitOperationProgress(processUid));
console.error(err);
}
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
formik.setFieldValue('collectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');
console.error(error);
});
};
const handleCollectionSelect = (collection) => {
setSelectedCollectionPaths((prevSelected) =>
prevSelected.includes(collection)
? prevSelected.filter((c) => c !== collection)
: [...prevSelected, collection]
);
};
const getRelativePath = (fullPath, pathname) => {
let relativePath = path.relative(fullPath, pathname);
const { dir, name } = path.parse(relativePath);
return path.join(dir, name);
};
const isScanCompleted = () => steps.some((step) => step.step === 'scan' && step.completed);
const isConfirmDisabled = () => isScanCompleted() && collectionPaths?.length > 0 && selectedCollectionPaths?.length === 0;
const isFooterHidden = () => steps.some((step) => !step.completed);
const isError = () => steps.some((step) => step.error);
const handleConfirm = () => {
const buttonText = getConfirmText();
switch (buttonText) {
case 'Clone':
formik.handleSubmit();
break;
case 'Close':
onClose();
break;
case 'Open':
if (collectionPaths.length > 0 && selectedCollectionPaths.length > 0) {
dispatch(openMultipleCollections(selectedCollectionPaths));
onClose();
onFinish();
}
break;
default:
break;
}
};
const getConfirmText = () =>
!steps.length
? 'Clone'
: steps.some((step) => !step.completed || step.error || (isScanCompleted() && !collectionPaths?.length))
? 'Close'
: 'Open';
const handleBackButtonClick = () => {
setView('form');
setSteps([]);
setSelectedCollectionPaths([]);
};
if (!gitVersion) {
return <GitNotFoundModal onClose={onClose} />;
}
return (
<Portal id="clone-repository-portal">
<Modal
size="md"
title="Clone Git Repository"
confirmText={getConfirmText()}
handleConfirm={handleConfirm}
handleCancel={onClose}
confirmDisabled={isConfirmDisabled()}
hideFooter={isFooterHidden()}
hideCancel={isError() || (isScanCompleted() && !collectionPaths?.length)}
showBackButton={isError()}
handleBack={handleBackButtonClick}
>
<StyledWrapper>
{view === 'form' && (
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
{collectionRepositoryUrl
? (
<div className="flex items-start">
<div className="flex-shrink-0 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<IconBrandGit className="w-6 h-6 text-purple-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-semibold text-sm">{getRepoNameFromUrl(collectionRepositoryUrl)}</div>
<div className="mt-1 text-xs text-muted font-mono">
{collectionRepositoryUrl}
</div>
</div>
</div>
)
: (
<>
<label htmlFor="repository-url" className="flex items-center font-semibold">
Git Repository URL
</label>
<input
id="repository-url"
type="text"
name="repositoryUrl"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.repositoryUrl || ''}
/>
</>
)}
{formik.touched.repositoryUrl && formik.errors.repositoryUrl && (
<div className="text-red-500">{formik.errors.repositoryUrl}</div>
)}
<label htmlFor="collection-location" className="block font-semibold mt-3">
Location
</label>
<input
id="collection-location"
type="text"
name="collectionLocation"
readOnly
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation && (
<div className="text-red-500">{formik.errors.collectionLocation}</div>
)}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
</div>
</div>
</form>
)}
{view === 'progress' && (
<>
{steps.length > 0 && (
<div className="mt-4">
<ul>
{steps.map((step, index) => (
<li key={index} className="flex-col items-center space-x-2 mt-1">
<div className="flex">
{step.error ? (
<IconAlertCircle className="text-red-500" size={18} strokeWidth={1.5} />
) : (
<>
{step.completed ? (
<IconCheck className="text-green-500" size={18} strokeWidth={1.5} />
) : (
<IconRefresh className="text-yellow-500 animate-spin" size={18} strokeWidth={1.5} />
)}
</>
)}
<span className="ml-2">{step.title}</span>
</div>
{step.info && (
<div className="w-full mt-2">
<pre className="info-box ml-4">{step.info}</pre>
</div>
)}
</li>
))}
</ul>
</div>
)}
{isScanCompleted() && (
<div className="mt-4 mb-4">
{collectionPaths.length === 0 && (
<div className="flex">
<IconAlertCircle className="text-yellow-500" size={18} strokeWidth={1.5} />
<h3 className="text-sm ml-2">No bruno collections found in this repository.</h3>
</div>
)}
{collectionPaths.length > 0 && (
<>
<h3 className="text-sm mb-2">
{collectionPaths.length} bruno collections found. Please select the collections to open:
</h3>
<ul>
{collectionPaths.map((collection) => (
<li key={collection} className="mb-2">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={selectedCollectionPaths.includes(collection)}
onChange={() => handleCollectionSelect(collection)}
className="form-checkbox"
/>
<span>{getRelativePath(formik.values.collectionLocation, collection)}</span>
</label>
</li>
))}
</ul>
</>
)}
</div>
)}
</>
)}
</StyledWrapper>
</Modal>
</Portal>
);
};
export default CloneGitRepository;

View File

@@ -26,7 +26,7 @@ const CloneCollection = ({ onClose, collectionUid }) => {
const isDefaultWorkspace = activeWorkspace?.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultCollectionLocation', '')
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const { name } = collection;

View File

@@ -3,7 +3,7 @@ import toast from 'react-hot-toast';
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 { isItemAFolder } from 'utils/tabs';
import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
@@ -18,6 +18,7 @@ import Button from 'ui/Button';
const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
const isFolder = isItemAFolder(item);
const inputRef = useRef();
const [isEditing, toggleEditing] = useState(false);
@@ -168,7 +169,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
onChange={formik.handleChange}
value={formik.values.filename || ''}
/>
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.bru</span>}
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.{collection?.format || 'bru'}</span>}
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">
@@ -202,7 +203,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
Cancel
</Button>
<Button type="submit">
<Button type="submit" data-testid="collection-item-clone">
Clone
</Button>
</div>

View File

@@ -2,8 +2,7 @@ import React from 'react';
import Modal from 'components/Modal';
import { isItemAFolder } from 'utils/tabs';
import { useDispatch } from 'react-redux';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { deleteItem, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';

View File

@@ -3,8 +3,7 @@ import Modal from 'components/Modal';
import Portal from 'components/Portal';
import { useDispatch } from 'react-redux';
import { deleteResponseExample } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => {
const dispatch = useDispatch();

View File

@@ -44,6 +44,7 @@ const CodeView = ({ language, item }) => {
<StyledWrapper>
<CopyToClipboard
text={snippet}
options={{ format: 'text/plain' }}
onCopy={() => toast.success('Copied to clipboard!')}
>
<button className="copy-to-clipboard">

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