Compare commits

..

79 Commits

Author SHA1 Message Date
Bijin A B
6aacaed9e3 chore: update coderabbit instructions to make sure the code is os agnostic 2026-03-04 10:39:09 +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
398 changed files with 16841 additions and 2606 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

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

110
package-lock.json generated
View File

@@ -30,7 +30,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",
@@ -6136,9 +6136,9 @@
}
},
"node_modules/@opencollection/types": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.7.0.tgz",
"integrity": "sha512-CSwdaHNPa2bNNBAOy++t6W9gBTExUJZW3aPkWyhAjasusThbvjymD/0uCLR50gCXSs0ezv61jsd19m9x+2DMtQ==",
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.8.0.tgz",
"integrity": "sha512-YnogiJdyN/BTf9lu+eTwmhAOiOwAT2cuPXv7ePvQsVT6r6gCALDR2IhD8ISergR/fQBgELWvlfj+lh/qTQ6sZw==",
"dev": true,
"license": "MIT"
},
@@ -9356,17 +9356,6 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/@swagger-api/apidom-parser-adapter-yaml-1-2/node_modules/tree-sitter": {
"version": "0.22.4",
"resolved": "https://registry.npmjs.org/tree-sitter/-/tree-sitter-0.22.4.tgz",
"integrity": "sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
}
},
"node_modules/@swagger-api/apidom-reference": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-reference/-/apidom-reference-1.4.0.tgz",
@@ -14555,6 +14544,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/default-shell": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/default-shell/-/default-shell-2.2.0.tgz",
"integrity": "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
@@ -15993,7 +15994,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
"integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.3",
@@ -16017,7 +16017,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -18290,7 +18289,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
"integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=10.17.0"
@@ -21463,7 +21461,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true,
"license": "MIT"
},
"node_modules/merge2": {
@@ -22055,7 +22052,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
"integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.0.0"
@@ -26683,6 +26679,50 @@
"node": ">=8"
}
},
"node_modules/shell-env": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/shell-env/-/shell-env-4.0.2.tgz",
"integrity": "sha512-8VJLnsyY//uoDJYl7hBcPdX54x0LaKbbfo5htiv8v/jrR4MD7uRUEom6Cb+S54ugMM9GkBbQJSwlLNCI3VXAHQ==",
"license": "MIT",
"dependencies": {
"default-shell": "^2.0.0",
"execa": "^5.1.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/shell-env/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/shell-env/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/shell-quote": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
@@ -26781,7 +26821,6 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"devOptional": true,
"license": "ISC"
},
"node_modules/simple-concat": {
@@ -27389,7 +27428,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -33327,7 +33365,7 @@
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@opencollection/types": "~0.5.0",
"@opencollection/types": "~0.8.0",
"@rollup/plugin-alias": "^5.1.0",
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -33343,13 +33381,6 @@
"typescript": "^4.8.4"
}
},
"packages/bruno-converters/node_modules/@opencollection/types": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/@opencollection/types/-/types-0.5.0.tgz",
"integrity": "sha512-9rpu5agMrMLcMVU2UgyV+PYV3Zf/sHBJDHMQoq8XiMEUH8lt9f7yGtlerm/9dS3SHMpGX4A8ik0OFtc0dX4r1Q==",
"dev": true,
"license": "MIT"
},
"packages/bruno-converters/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -35213,7 +35244,8 @@
"tv4": "^1.3.0",
"uuid": "^9.0.0",
"xml-formatter": "^3.5.0",
"xml2js": "^0.6.2"
"xml2js": "^0.6.2",
"yaml": "^2.3.4"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
@@ -35283,6 +35315,21 @@
"node": ">=10"
}
},
"packages/bruno-js/node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"packages/bruno-lang": {
"name": "@usebruno/lang",
"version": "0.12.0",
@@ -35325,6 +35372,7 @@
"http-proxy-agent": "~7.0.2",
"https-proxy-agent": "~7.0.6",
"is-ip": "^5.0.1",
"shell-env": "^4.0.1",
"socks-proxy-agent": "~8.0.5",
"system-ca": "^2.0.1",
"tough-cookie": "^6.0.0",

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",

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

@@ -13,7 +13,8 @@
"api/*": ["src/api/*"],
"pageComponents/*": ["src/pageComponents/*"],
"providers/*": ["src/providers/*"],
"utils/*": ["src/utils/*"]
"utils/*": ["src/utils/*"],
"store/*": ["src/store/*"]
}
},
"exclude": ["node_modules", "dist"]

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);
}
}
@@ -236,7 +243,8 @@ export default class CodeEditor extends React.Component {
// 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) {
// 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();
@@ -287,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

@@ -1,7 +1,6 @@
import React from 'react';
import React, { useMemo } from 'react';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { IconFolder, IconWorld, IconApi, IconShare, IconBook } from '@tabler/icons';
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
import { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ShareCollection from 'components/ShareCollection/index';
@@ -11,10 +10,13 @@ import StyledWrapper from './StyledWrapper';
const Info = ({ collection }) => {
const dispatch = useDispatch();
const totalRequestsInCollection = getTotalRequestCountInCollection(collection);
const isCollectionLoading = areItemsLoading(collection);
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
const isCollectionLoading = collection.isLoading;
const totalRequestsInCollection = useMemo(
() => getTotalRequestCountInCollection(collection),
[collection.items]
);
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
@@ -95,7 +97,9 @@ const Info = ({ collection }) => {
<div className="font-medium">Requests</div>
<div className="mt-1 text-muted">
{
isCollectionLoading ? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
isCollectionLoading
? 'Loading requests...'
: `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
}
</div>
</div>

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

@@ -31,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,
@@ -42,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;
@@ -50,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();
@@ -407,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={(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;
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() === '') ? 'Name' : ''}
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

@@ -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() === '') {

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';
@@ -23,6 +24,7 @@ import {
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';
@@ -40,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('');
@@ -65,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;
@@ -497,6 +508,12 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -531,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
@@ -553,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>
@@ -565,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

@@ -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);
@@ -179,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,67 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.cache-stats {
padding: 1rem;
border-radius: ${(props) => props.theme.border.radius.md};
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
margin-bottom: 1rem;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid ${(props) => props.theme.input.border};
&:last-child {
border-bottom: none;
}
}
.stat-label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.stat-value {
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
}
.purge-button {
padding: 0.5rem 1rem;
border-radius: ${(props) => props.theme.border.radius.sm};
font-size: ${(props) => props.theme.font.size.sm};
cursor: pointer;
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
&:hover:not(:disabled) {
border-color: ${(props) => props.theme.input.focusBorder};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.description {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
margin-top: 0.5rem;
}
.section-title {
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,89 @@
import React, { useState, useEffect, useCallback } from 'react';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
const Cache = () => {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [purging, setPurging] = useState(false);
const fetchStats = useCallback(async () => {
try {
const cacheStats = await window.ipcRenderer.invoke('renderer:get-cache-stats');
setStats(cacheStats);
} catch (error) {
console.error('Error fetching cache stats:', error);
setStats({ error: error.message });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
const handlePurgeCache = async () => {
setPurging(true);
try {
const result = await window.ipcRenderer.invoke('renderer:purge-cache');
if (result.success) {
toast.success('Cache purged successfully');
await fetchStats();
} else {
toast.error(result.error || 'Failed to purge cache');
}
} catch (error) {
console.error('Error purging cache:', error);
toast.error('Failed to purge cache');
} finally {
setPurging(false);
}
};
return (
<StyledWrapper className="w-full">
<div className="section-title">Collection Cache</div>
<p className="description mb-4">
Bruno caches parsed collection files to improve loading performance. Clearing the cache will cause collections to be fully re-parsed on next load.
</p>
<div className="cache-stats">
{loading ? (
<div className="stat-item">
<span className="stat-label">Loading...</span>
</div>
) : stats?.error ? (
<div className="stat-item">
<span className="stat-label">Error: {stats.error}</span>
</div>
) : (
<>
<div className="stat-item">
<span className="stat-label">Cached Collections</span>
<span className="stat-value">{stats?.totalCollections ?? 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Cached Files</span>
<span className="stat-value">{stats?.totalFiles ?? 0}</span>
</div>
<div className="stat-item">
<span className="stat-label">Cache Version</span>
<span className="stat-value">{stats?.version ?? 'N/A'}</span>
</div>
</>
)}
</div>
<button
className="purge-button"
onClick={handlePurgeCache}
disabled={purging || loading}
>
{purging ? 'Purging...' : 'Purge Cache'}
</button>
</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

@@ -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';
@@ -19,6 +20,7 @@ import Proxy from './ProxySettings';
import Display from './Display';
import Keybindings from './Keybindings';
import Beta from './Beta';
import Cache from './Cache';
import StyledWrapper from './StyledWrapper';
@@ -62,6 +64,10 @@ const Preferences = () => {
return <Beta />;
}
case 'cache': {
return <Cache />;
}
case 'support': {
return <Support />;
}
@@ -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

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

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,9 +73,13 @@ 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

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

@@ -17,8 +17,7 @@ const StyledWrapper = styled.div`
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
font-size: 15px;
font-weight: 600;
font-weight: 500;
transition: background-color 0.15s ease;
&:hover {
@@ -30,6 +29,11 @@ const StyledWrapper = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.scratch-collection {
font-weight: 600;
font-size: 15px;
}
}
.tab-count {

View File

@@ -325,8 +325,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
icon={(
<button className="switcher-trigger">
<DisplayIcon size={18} strokeWidth={1.5} />
<span className="switcher-name">{displayName}</span>
{tabCount > 0 && <span className="tab-count">{tabCount}</span>}
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
</button>
)}

View File

@@ -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();

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

@@ -1,6 +1,6 @@
import React, { useMemo, useCallback, memo } from 'react';
import { useSelector } from 'react-redux';
import { IconDatabase, IconCheck, IconLoader2 } from '@tabler/icons';
import { IconDatabase, IconLoader2 } from '@tabler/icons';
import { areItemsLoading } from 'utils/collections';
const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => {
@@ -8,11 +8,10 @@ const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName
state.collections.collections.find((c) => c.uid === collectionUid || c.pathname === collectionPath)
);
const { isFullyLoaded, isLoading } = useMemo(() => {
const isLoading = useMemo(() => {
const isMounted = collection?.mountStatus === 'mounted';
const fullyLoaded = isMounted && !areItemsLoading(collection);
const loading = isSelected && !fullyLoaded;
return { isFullyLoaded: fullyLoaded, isLoading: loading };
return isSelected && !fullyLoaded;
}, [collection, isSelected]);
const handleClick = useCallback(() => {
@@ -33,9 +32,6 @@ const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName
{isLoading && (
<IconLoader2 size={16} strokeWidth={1.5} className="animate-spin" />
)}
{isFullyLoaded && (
<IconCheck size={16} strokeWidth={1.5} className="icon-success" />
)}
</li>
);
});

View File

@@ -204,7 +204,7 @@ const StyledWrapper = styled.div`
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};
@@ -370,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,4 +1,4 @@
import React, { useState, useMemo, useEffect, useRef, useCallback } 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';
@@ -14,7 +14,7 @@ import FolderBreadcrumbs from './FolderBreadcrumbs';
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
import { removeSaveTransientRequestModal } from 'providers/ReduxStore/slices/collections';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { newFolder, closeTabs, mountCollection } from 'providers/ReduxStore/slices/collections/actions';
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 path from 'utils/common/path';
@@ -23,6 +23,7 @@ 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();
@@ -39,6 +40,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
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 [];
@@ -66,7 +72,9 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const [showFilesystemName, setShowFilesystemName] = useState(false);
const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false);
const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null);
const newFolderInputRef = useRef(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);
@@ -111,6 +119,8 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setPendingFolderNavigation(null);
setSelectedTargetCollectionPath(null);
setIsSelectingCollection(isScratchCollection);
// Reset new collection state
setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
}, [item?.name, isScratchCollection, reset]);
useEffect(() => {
@@ -119,12 +129,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
}
}, [isOpen, item, resetForm]);
useEffect(() => {
if (showNewFolderInput && newFolderInputRef.current) {
newFolderInputRef.current.focus();
}
}, [showNewFolderInput]);
useEffect(() => {
if (pendingFolderNavigation) {
const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation);
@@ -298,6 +302,48 @@ 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('');
@@ -377,7 +423,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
{isSelectingCollection ? (
<div className="collection-list">
{availableCollections.length > 0 ? (
{availableCollections.length > 0 || newCollection.show ? (
<ul className="collection-list-items">
{availableCollections.map((coll) => {
const collPath = coll.path || coll.pathname;
@@ -392,10 +438,117 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
/>
);
})}
{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="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">
No collections available in workspace. Please add a collection to the workspace first.
<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>
@@ -448,7 +601,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
</div>
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
ref={(node) => node?.focus()}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
@@ -595,6 +748,17 @@ 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}>

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

@@ -133,7 +133,7 @@ export const BulkImportCollectionLocation = ({
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultCollectionLocation', '')
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const [status, setStatus] = useState({});

View File

@@ -33,7 +33,7 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultCollectionLocation', '')
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const inputRef = useRef();
const dispatch = useDispatch();

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

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

View File

@@ -95,9 +95,14 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
// interpolate the path params
const finalUrl = interpolateUrlPathParams(
interpolatedUrl,
requestData.params
requestData.params,
variables
);
// Raw URL: path params resolved via string replacement (no new URL() encoding),
// preserving the user's original encoding choices for snippet generation.
const rawUrl = interpolateUrlPathParams(interpolatedUrl, requestData.params, variables, { raw: true });
// Get the full language object based on current preferences
const selectedLanguage = useMemo(() => {
const fullName = generateCodePrefs.library === 'default'
@@ -119,7 +124,8 @@ const GenerateCodeItem = ({ collectionUid, item, onClose, isExample = false, exa
...requestData.request,
auth: resolvedRequest.auth,
url: finalUrl
}
},
rawUrl
};
// Update modal title based on mode

View File

@@ -4,6 +4,9 @@ import { getAllVariables, getTreePathFromCollectionToItem, mergeHeaders } from '
import { resolveInheritedAuth } from 'utils/auth';
import { get } from 'lodash';
import { interpolateAuth, interpolateHeaders, interpolateBody, interpolateParams } from './interpolation';
import { encodeUrl as encodeUrlCommon, stripOrigin } from '@usebruno/common/utils';
import { parse } from 'url';
import { stringify } from 'query-string';
const addCurlAuthFlags = (curlCommand, auth) => {
if (!auth || !curlCommand) return curlCommand;
@@ -79,6 +82,38 @@ const generateSnippet = ({ language, item, collection, shouldInterpolate = false
result = addCurlAuthFlags(result, effectiveAuth);
}
// Respect encodeUrl setting: when not explicitly true, replace HTTPSnippet's encoded path+query with the raw version.
// Replacing the path portion works for all targets since it's a substring of the full URL.
// encodeUrl defaults to false in the UI when undefined/null
const settings = item.draft ? get(item, 'draft.settings') : get(item, 'settings');
const rawUrl = item.rawUrl || request.url;
const parsed = parse(request.url, true, true);
const search = stringify(parsed.query);
const httpSnippetPath = search ? `${parsed.pathname}?${search}` : parsed.pathname;
let desiredPath;
if (settings?.encodeUrl === true) {
// Apply the same encodeUrl() transform used by the actual request execution path
// so the snippet matches what's sent on the wire.
const encodedUrl = encodeUrlCommon(rawUrl);
desiredPath = stripOrigin(encodedUrl);
// Strip fragment per RFC 3986 §3.5
desiredPath = desiredPath.replace(/#.*$/, '');
} else {
desiredPath = stripOrigin(rawUrl);
// The HTTP raw target (http/http1.1) uses the request line format:
// METHOD <request-target> HTTP-version
// Spaces delimit these fields, so a literal space in the request-target
// would be parsed as the end of the URI (RFC 7230 §3.1.1).
if (language.target === 'http') {
desiredPath = desiredPath.replace(/ /g, '%20');
}
}
if (httpSnippetPath !== desiredPath && httpSnippetPath?.length > 1) {
result = result.replaceAll(httpSnippetPath, desiredPath);
}
return result;
} catch (error) {
console.error('Error generating code snippet:', error);

View File

@@ -906,3 +906,338 @@ describe('generateSnippet digest and NTLM auth curl export', () => {
expect(result).toMatch(/^curl --digest --user 'myuser'/);
});
});
describe('generateSnippet encodeUrl setting', () => {
const language = { target: 'shell', client: 'curl' };
const baseCollection = { root: { request: { auth: { mode: 'none' }, headers: [] } } };
// Replicate HTTPSnippet's internal encoding to get encoded path+query
const getEncodedPath = (url) => {
const { parse } = require('url');
const { stringify } = require('query-string');
const parsed = parse(url, true, true);
if (!parsed.query || Object.keys(parsed.query).length === 0) {
return parsed.pathname;
}
const search = stringify(parsed.query);
return search ? `${parsed.pathname}?${search}` : parsed.pathname;
};
const makeItem = (url, settings, draft) => ({
uid: 'enc-req',
request: {
method: 'GET',
url,
headers: [],
body: { mode: 'none' },
auth: { mode: 'none' }
},
...(settings !== undefined && { settings }),
...(draft !== undefined && { draft })
});
beforeEach(() => {
jest.clearAllMocks();
// Mock HTTPSnippet to simulate encoding (same pipeline as the real library)
require('httpsnippet').HTTPSnippet = jest.fn().mockImplementation((harRequest) => ({
convert: jest.fn((target) => {
const method = harRequest?.method || 'GET';
const url = harRequest?.url || 'http://example.com';
const { parse } = require('url');
const parsed = parse(url, false, true);
const encodedPath = getEncodedPath(url);
// Simulate targets that use only the path (e.g., python http.client, raw HTTP)
if (target === 'python') {
return `conn.request("${method}", "${encodedPath}", headers=headers)`;
}
// Full URL targets: reconstruct with encoded path
const fullEncodedUrl = `${parsed.protocol}//${parsed.host}${encodedPath}`;
return `curl -X ${method} '${fullEncodedUrl}'`;
})
}));
});
it('should preserve equals signs in query values when encodeUrl is false', () => {
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('token=abc123==');
// %3D = encoded '='
expect(result).not.toContain('%3D');
});
it('should preserve email with plus alias and @ when encodeUrl is false', () => {
const rawUrl = 'https://example.com/invite?email=test+alias@example.com';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('email=test+alias@example.com');
});
it('should preserve redirect URL with colons and slashes when encodeUrl is false', () => {
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback&scope=read';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('redirect=https://other.com/callback');
// %3A = encoded ':'
expect(result).not.toContain('%3A');
// %2F = encoded '/'
expect(result).not.toContain('%2F');
});
it('should preserve comma-separated values when encodeUrl is false', () => {
const rawUrl = 'https://example.com/filter?tags=a,b,c&time=10:30';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('tags=a,b,c');
expect(result).toContain('time=10:30');
});
it('should encode URL when encodeUrl is true', () => {
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// %3D%3D = encoded '=='
expect(result).toContain('%3D%3D');
});
it('should preserve raw URL when settings are absent (encodeUrl defaults to false)', () => {
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback';
const item = makeItem(rawUrl);
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('redirect=https://other.com/callback');
// %3A = encoded ':'
expect(result).not.toContain('%3A');
});
it('should be a no-op for URLs without query params and no encoding needed', () => {
const rawUrl = 'https://example.com/api/users';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toBe(`curl -X GET '${rawUrl}'`);
});
it('should preserve spaces in pathname when encodeUrl is false and rawUrl is provided', () => {
const encodedUrl = 'https://example.com/my%20path/hello%20world?token=abc123==';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/my path/hello world?token=abc123=='
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('/my path/hello world?token=abc123==');
expect(result).not.toContain('%20');
expect(result).not.toContain('%3D');
});
it('should preserve spaces in pathname without query params when encodeUrl is false', () => {
const encodedUrl = 'https://example.com/my%20path/hello%20world';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/my path/hello world'
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('/my path/hello world');
expect(result).not.toContain('%20');
});
it('should preserve spaces in path-only targets (e.g., python) when encodeUrl is false', () => {
const pythonLanguage = { target: 'python', client: 'python3' };
const encodedUrl = 'https://example.com/my%20path/hello%20world?q=test';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/my path/hello world?q=test'
};
const result = generateSnippet({ language: pythonLanguage, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('/my path/hello world?q=test');
expect(result).not.toContain('%20');
});
it('should preserve spaces in query values when encodeUrl is false and rawUrl is provided', () => {
const encodedUrl = 'https://example.com/api?token=abc%20123==&type=test';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/api?token=abc 123==&type=test'
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('token=abc 123==');
expect(result).not.toContain('%20');
expect(result).not.toContain('%3D');
});
it('should still work when rawUrl is not provided (backward compatibility)', () => {
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('token=abc123==');
expect(result).not.toContain('%3D');
});
it('should keep spaces as %20 for http target when encodeUrl is false (HTTP spec compliance)', () => {
const httpLanguage = { target: 'http', client: 'http1.1' };
const encodedUrl = 'https://example.com/api?token=abc%20123==&type=test';
const item = {
...makeItem(encodedUrl, { encodeUrl: false }),
rawUrl: 'https://example.com/api?token=abc 123==&type=test'
};
const result = generateSnippet({ language: httpLanguage, item, collection: baseCollection, shouldInterpolate: false });
// Spaces must remain encoded for valid HTTP request line
expect(result).toContain('%20');
// But other chars like = should still be decoded
expect(result).not.toContain('%3D');
});
it('should preserve user-typed %20 when encodeUrl is false (not decode to space)', () => {
const preEncodedUrl = 'https://example.com/api?token=abc%20123%3D%3D&type=test';
const item = {
...makeItem(preEncodedUrl, { encodeUrl: false }),
rawUrl: preEncodedUrl // rawUrl has %20 intact (no decodeURI applied)
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// %20 should be preserved, not decoded to a literal space
expect(result).toContain('%20');
// %3D should also be preserved
expect(result).toContain('%3D%3D');
// No double-encoding
expect(result).not.toContain('%2520');
expect(result).not.toContain('%253D');
});
it('should double-encode pre-encoded %20 when encodeUrl is true', () => {
const preEncodedUrl = 'https://example.com/api?token=abc%20123%3D%3D&type=test';
const item = {
...makeItem(preEncodedUrl, { encodeUrl: true }),
rawUrl: preEncodedUrl
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// %20 → %2520 because encodeURIComponent encodes the literal '%' in the already-encoded value
expect(result).toContain('%2520');
// %3D → %253D for the same reason
expect(result).toContain('%253D');
});
it('should preserve OData-style paths with parenthesized params when encodeUrl is false', () => {
const rawUrl = 'https://example.com/odata/Products(123)/Categories(456)?$expand=Items&$filter=Price gt 10';
const item = {
...makeItem(rawUrl, { encodeUrl: false }),
rawUrl
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('Products(123)/Categories(456)');
expect(result).toContain('$expand=Items');
expect(result).toContain('$filter=Price gt 10');
// $ should not be encoded
expect(result).not.toContain('%24');
});
it('should use draft settings when draft exists', () => {
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: true }, { settings: { encodeUrl: false } });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('token=abc123==');
// %3D%3D = encoded '=='
expect(result).not.toContain('%3D%3D');
});
it('should replace encoded path for targets that use only path+query (e.g., python http.client)', () => {
const pythonLanguage = { target: 'python', client: 'python3' };
const rawUrl = 'https://example.com/api?token=abc123==&type=test';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language: pythonLanguage, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('/api?token=abc123==&type=test');
// %3D = encoded '='
expect(result).not.toContain('%3D');
});
it('should preserve URL fragment (#) in snippet when encodeUrl is false', () => {
// Intentional asymmetry: when encodeUrl is false (raw mode), generateSnippet preserves the
// user-supplied URL as-is, including any fragment. This contrasts with encodeUrl: true,
// which strips fragments per RFC 3986 §3.5. The rawUrl is preserved through the makeItem
// call with { encodeUrl: false } and passed to generateSnippet, which intentionally treats
// it as a user-specified string not subject to RFC-compliant stripping. This is a designed
// behavior to honor user intent in raw mode, not a bug. This behavior can be revisited in
// the future if requirements or RFC interpretations change.
const rawUrl = 'https://example.com/api?token=abc==#section';
const item = makeItem(rawUrl, { encodeUrl: false });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toContain('#section');
expect(result).toContain('token=abc==');
expect(result).not.toContain('%3D');
});
it('should not include URL fragment (#) in snippet when encodeUrl is true', () => {
const rawUrl = 'https://example.com/api?token=abc==#section';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// Fragment is stripped — correct per RFC 3986 §3.5: user agents MUST NOT include the fragment
// in the HTTP request target sent to the origin server (though fragments can still appear in
// user-facing URLs, SPA routing, and are inherited across redirects per RFC 9110 §10.2.2).
// https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
// https://datatracker.ietf.org/doc/html/rfc9110#section-10.2.2
expect(result).not.toContain('#section');
expect(result).toContain('%3D%3D');
});
it('should single-encode spaces and special chars when encodeUrl is true and rawUrl is provided', () => {
// The raw URL (before new URL() encoding) contains literal spaces and @.
// encodeUrl() should encode them once: space → %20, @ → %40.
// Previously this double-encoded because request.url was already encoded by new URL().
const encodedUrl = 'https://example.com/api?name=abc%20os&email=user%40test.com';
const item = {
...makeItem(encodedUrl, { encodeUrl: true }),
rawUrl: 'https://example.com/api?name=abc os&email=user@test.com'
};
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// space → %20 (single encoding, not %2520)
expect(result).toContain('%20');
expect(result).not.toContain('%2520');
// @ → %40 (single encoding, not %2540)
expect(result).toContain('%40');
expect(result).not.toContain('%2540');
});
it('should encode special chars in query values when encodeUrl is true (e.g., redirect URLs)', () => {
const rawUrl = 'https://example.com/auth?redirect=https://other.com/callback&scope=read';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// : → %3A, / → %2F when encodeURIComponent is applied to query values
expect(result).toContain('%3A');
expect(result).toContain('%2F');
});
it('should strip fragment and apply encodeUrl when both are present and encodeUrl is true', () => {
const rawUrl = 'https://example.com/api?redirect=https://other.com/cb#section';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
// Fragment stripped per RFC 3986
expect(result).not.toContain('#section');
// Query value should be encoded
expect(result).toContain('%3A');
expect(result).toContain('%2F');
});
it('should be a no-op for path-only URLs when encodeUrl is true (no query params to encode)', () => {
const rawUrl = 'https://example.com/api/users';
const item = makeItem(rawUrl, { encodeUrl: true });
const result = generateSnippet({ language, item, collection: baseCollection, shouldInterpolate: false });
expect(result).toBe(`curl -X GET '${rawUrl}'`);
});
});

View File

@@ -2,7 +2,7 @@ import React, { useRef, useEffect, useState, forwardRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { renameItem, saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import path from 'utils/common/path';
@@ -18,6 +18,7 @@ import Button from 'ui/Button';
const RenameCollectionItem = ({ 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,6 +169,7 @@ const RenameCollectionItem = ({ collectionUid, item, onClose }) => {
size={16}
strokeWidth={1.5}
onClick={() => toggleEditing(true)}
data-testid="rename-request-edit-icon"
/>
)}
</div>
@@ -186,7 +188,7 @@ const RenameCollectionItem = ({ 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">

View File

@@ -157,6 +157,19 @@ const Wrapper = styled.div`
}
}
.empty-folder-message {
display: flex;
align-items: center;
height: 1.6rem;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.sidebar.muted};
.add-request-link {
color: ${(props) => props.theme.textLink};
cursor: pointer;
}
}
&.is-sidebar-dragging .collection-item-name {
cursor: inherit;
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { getEmptyImage } from 'react-dnd-html5-backend';
import range from 'lodash/range';
import filter from 'lodash/filter';
@@ -39,6 +39,7 @@ import { doesRequestMatchSearchText, doesFolderHaveItemsMatchSearchText } from '
import { getDefaultRequestPaneTab } from 'utils/collections';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { getKeyBindingsForActionAllOS } from 'providers/Hotkeys/keyMappings';
import NetworkError from 'components/ResponsePane/NetworkError/index';
import CollectionItemInfo from './CollectionItemInfo/index';
import CollectionItemIcon from './CollectionItemIcon';
@@ -47,6 +48,7 @@ import ExampleIcon from 'components/Icons/ExampleIcon';
import { scrollToTheActiveTab } from 'utils/tabs';
import { isTabForItemActive as isTabForItemActiveSelector, isTabForItemPresent as isTabForItemPresentSelector } from 'src/selectors/tab';
import { isEqual } from 'lodash';
import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
import { calculateDraggedItemNewPathname, getInitialExampleName, findParentItemInCollection } from 'utils/collections/index';
import { sortByNameThenSequence } from 'utils/common/index';
import { getRevealInFolderLabel } from 'utils/common/platform';
@@ -67,12 +69,21 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const isSidebarDragging = useSelector((state) => state.app.isDragging);
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
const preferences = useSelector((state) => state.app.preferences);
const userKeyBindings = preferences?.keyBindings || {};
const hasCustomCopyBinding = !!userKeyBindings?.copyItem;
const hasCustomPasteBinding = !!userKeyBindings?.pasteItem;
const hasCustomRenameBinding = !!userKeyBindings?.renameItem;
const dispatch = useDispatch();
// We use a single ref for drag and drop.
const ref = useRef(null);
const menuDropdownRef = useRef(null);
// Refs to store current handler references for event listeners (avoid stale closures)
const copyHandlerRef = useRef(null);
const pasteHandlerRef = useRef(null);
const [renameItemModalOpen, setRenameItemModalOpen] = useState(false);
const [cloneItemModalOpen, setCloneItemModalOpen] = useState(false);
const [deleteItemModalOpen, setDeleteItemModalOpen] = useState(false);
@@ -119,6 +130,52 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
}
}, [isTabForItemActive]);
// Listen for clone-item-open event from Hotkeys provider
const isFocusedRef = useRef(isKeyboardFocused);
isFocusedRef.current = isKeyboardFocused;
useEffect(() => {
const handleCloneItemOpen = () => {
// Only open modal if this item is keyboard focused
if (isFocusedRef.current) {
setCloneItemModalOpen(true);
}
};
const handleCopyItemOpen = () => {
// Copy item to clipboard if this item is keyboard focused
if (isFocusedRef.current && copyHandlerRef.current) {
copyHandlerRef.current();
}
};
const handlePasteItemOpen = () => {
// Paste item from clipboard if this item is keyboard focused
if (isFocusedRef.current && pasteHandlerRef.current) {
pasteHandlerRef.current();
}
};
const handleRenameItemOpen = () => {
// Rename item if this item is keyboard focused
if (isFocusedRef.current) {
setRenameItemModalOpen(true);
}
};
window.addEventListener('clone-item-open', handleCloneItemOpen);
window.addEventListener('copy-item-open', handleCopyItemOpen);
window.addEventListener('paste-item-open', handlePasteItemOpen);
window.addEventListener('rename-item-open', handleRenameItemOpen);
return () => {
window.removeEventListener('clone-item-open', handleCloneItemOpen);
window.removeEventListener('copy-item-open', handleCopyItemOpen);
window.removeEventListener('paste-item-open', handlePasteItemOpen);
window.removeEventListener('rename-item-open', handleRenameItemOpen);
};
}, []);
const determineDropType = (monitor) => {
const hoverBoundingRect = ref.current?.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
@@ -463,7 +520,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const exampleData = {
name: name,
description: description,
status: '200',
status: 200,
statusText: 'OK',
headers: [],
body: {
@@ -504,6 +561,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient));
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient));
const showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !requestItems?.length;
const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid });
const handleGenerateCode = () => {
if (
@@ -532,13 +592,13 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
}
};
const handleCopyItem = () => {
const handleCopyItem = useCallback(() => {
dispatch(copyRequest(item));
const itemType = isFolder ? 'Folder' : 'Request';
toast.success(`${itemType} copied`);
};
}, [dispatch, item, isFolder]);
const handlePasteItem = () => {
const handlePasteItem = useCallback(() => {
// Determine target folder: if item is a folder, paste into it; otherwise paste into parent folder
let targetFolderUid = item.uid;
if (!isFolder) {
@@ -553,7 +613,11 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
.catch((err) => {
toast.error(err ? err.message : 'An error occurred while pasting the item');
});
};
}, [dispatch, collection, item, isFolder, collectionUid]);
// Update refs whenever handlers change
copyHandlerRef.current = handleCopyItem;
pasteHandlerRef.current = handlePasteItem;
// Keyboard shortcuts handler
const handleKeyDown = (e) => {
@@ -561,14 +625,19 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const isMac = navigator.userAgent?.includes('Mac') || navigator.platform?.startsWith('Mac');
const isModifierPressed = isMac ? e.metaKey : e.ctrlKey;
if (isModifierPressed && e.key.toLowerCase() === 'c') {
// Only use default handler if no custom keybinding is set for copy/paste
if (!hasCustomCopyBinding && isModifierPressed && e.key.toLowerCase() === 'c') {
e.preventDefault();
e.stopPropagation();
handleCopyItem();
} else if (isModifierPressed && e.key.toLowerCase() === 'v') {
if (copyHandlerRef.current) copyHandlerRef.current();
} else if (!hasCustomPasteBinding && isModifierPressed && e.key.toLowerCase() === 'v') {
e.preventDefault();
e.stopPropagation();
handlePasteItem();
if (pasteHandlerRef.current) pasteHandlerRef.current();
} else if (!hasCustomRenameBinding && e.key === 'F2') {
e.preventDefault();
e.stopPropagation();
setRenameItemModalOpen(true);
}
};
@@ -708,6 +777,25 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
return <CollectionItem key={i.uid} item={i} collectionUid={collectionUid} collectionPathname={collectionPathname} searchText={searchText} />;
})
: null}
{showEmptyFolderMessage ? (
<div className="empty-folder-message">
{range(item.depth + 1).map((i) => (
<div className="indent-block" key={i} style={{ width: 16, minWidth: 16, height: '100%' }}>
&nbsp;
</div>
))}
<div style={{ paddingLeft: 8 }}>
<MenuDropdown
items={emptyFolderMenuItems}
placement="bottom-start"
appendTo={dropdownContainerRef?.current || document.body}
popperOptions={{ strategy: 'fixed' }}
>
<button className="ml-1 add-request-link">+ Add request</button>
</MenuDropdown>
</div>
</div>
) : null}
</div>
) : null}

View File

@@ -95,6 +95,23 @@ const Wrapper = styled.div`
text-overflow: ellipsis;
overflow: hidden;
}
.indent-block {
border-right: 1px solid ${(props) => props.theme.sidebar.collection.item.indentBorder};
}
.empty-collection-message {
display: flex;
align-items: center;
height: 1.6rem;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.sidebar.muted};
.add-request-link {
color: ${(props) => props.theme.textLink};
cursor: pointer;
}
}
`;
export default Wrapper;

View File

@@ -32,13 +32,12 @@ import NewFolder from 'components/Sidebar/NewFolder';
import CollectionItem from './CollectionItem';
import RemoveCollection from './RemoveCollection';
import { doesCollectionHaveItemsMatchingSearchText } from 'utils/collections/search';
import { isItemAFolder, isItemARequest } from 'utils/collections';
import { isItemAFolder, isItemARequest, areItemsLoading } from 'utils/collections';
import { isTabForItemActive } from 'src/selectors/tab';
import RenameCollection from './RenameCollection';
import StyledWrapper from './StyledWrapper';
import CloneCollection from './CloneCollection';
import { areItemsLoading } from 'utils/collections';
import { scrollToTheActiveTab } from 'utils/tabs';
import ShareCollection from 'components/ShareCollection/index';
import GenerateDocumentation from './GenerateDocumentation';
@@ -49,6 +48,11 @@ import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
import ActionIcon from 'ui/ActionIcon';
import MenuDropdown from 'ui/MenuDropdown';
import { useSidebarAccordion } from 'components/Sidebar/SidebarAccordionContext';
import { createEmptyStateMenuItems } from 'utils/collections/emptyStateRequest';
// Delay before showing empty collection state (ms)
// This prevents flicker from race condition between loading state and item batch updates
const EMPTY_STATE_DELAY_MS = 300;
const Collection = ({ collection, searchText }) => {
const { dropdownContainerRef } = useSidebarAccordion();
@@ -61,9 +65,11 @@ const Collection = ({ collection, searchText }) => {
const [showRemoveCollectionModal, setShowRemoveCollectionModal] = useState(false);
const [dropType, setDropType] = useState(null);
const [isKeyboardFocused, setIsKeyboardFocused] = useState(false);
const [showEmptyState, setShowEmptyState] = useState(false);
const dispatch = useDispatch();
const isLoading = areItemsLoading(collection);
const collectionRef = useRef(null);
const itemCount = collection.items?.length || 0;
const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
@@ -258,6 +264,49 @@ const Collection = ({ collection, searchText }) => {
}
}, [isCollectionFocused]);
// Listen for clone-item-open event from Hotkeys provider
const isFocusedRef = useRef(isKeyboardFocused);
isFocusedRef.current = isKeyboardFocused;
useEffect(() => {
const handleCloneItemOpen = () => {
// Only open modal if this collection is keyboard focused
if (isFocusedRef.current) {
setShowCloneCollectionModalOpen(true);
}
};
const handleRenameCollectionOpen = () => {
// Only open rename collection modal if this collection is keyboard focused
if (isFocusedRef.current) {
setShowRenameCollectionModal(true);
}
};
window.addEventListener('clone-item-open', handleCloneItemOpen);
window.addEventListener('rename-item-open', handleRenameCollectionOpen);
return () => {
window.removeEventListener('clone-item-open', handleCloneItemOpen);
window.removeEventListener('rename-item-open', handleRenameCollectionOpen);
};
}, []);
// Debounce showing empty state to prevent flicker
// Race condition: isLoading can become false before items batch arrives from IPC
useEffect(() => {
const isMounted = collection.mountStatus === 'mounted';
const hasItems = itemCount > 0;
if (hasItems || isLoading || !isMounted) {
setShowEmptyState(false);
return;
}
const timer = setTimeout(() => setShowEmptyState(true), EMPTY_STATE_DELAY_MS);
return () => clearTimeout(timer);
}, [itemCount, isLoading, collection.mountStatus]);
if (searchText && searchText.length) {
if (!doesCollectionHaveItemsMatchingSearchText(collection, searchText)) {
return null;
@@ -278,6 +327,9 @@ const Collection = ({ collection, searchText }) => {
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient));
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient));
const showEmptyCollectionMessage = showEmptyState && !hasSearchText;
const emptyStateMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: null });
const menuItems = [
{
@@ -472,6 +524,23 @@ const Collection = ({ collection, searchText }) => {
{requestItems?.map?.((i) => {
return <CollectionItem key={i.uid} item={i} collectionUid={collection.uid} collectionPathname={collection.pathname} searchText={searchText} />;
})}
{showEmptyCollectionMessage ? (
<div className="empty-collection-message">
<div className="indent-block" style={{ width: 16, minWidth: 16, height: '100%' }}>
&nbsp;
</div>
<div style={{ paddingLeft: 8 }}>
<MenuDropdown
items={emptyStateMenuItems}
placement="bottom-start"
appendTo={dropdownContainerRef?.current || document.body}
popperOptions={{ strategy: 'fixed' }}
>
<button className="ml-1 add-request-link">+ Add request</button>
</MenuDropdown>
</div>
</div>
) : null}
</div>
) : null}
</div>

View File

@@ -32,7 +32,7 @@ const CreateCollection = ({ onClose, defaultLocation: propDefaultLocation }) =>
const activeWorkspace = workspaces.find((w) => w.uid === workspaceUid);
const isDefaultWorkspace = activeWorkspace?.type === 'default';
const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultCollectionLocation', '') : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const defaultLocation = isDefaultWorkspace ? get(preferences, 'general.defaultLocation', '') : (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const formik = useFormik({
enableReinitialize: true,

View File

@@ -110,7 +110,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultCollectionLocation', '')
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const collectionName = getCollectionName(format, rawData);

View File

@@ -104,6 +104,7 @@ const NewFolder = ({ collectionUid, item, onClose }) => {
formik.setFieldValue('folderName', e.target.value);
!isEditing && formik.setFieldValue('directoryName', sanitizeName(e.target.value));
}}
data-testid="new-folder-input"
value={formik.values.folderName || ''}
/>
{formik.touched.folderName && formik.errors.folderName ? (

View File

@@ -1,5 +1,6 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import {
IconArrowsSort,
@@ -15,10 +16,13 @@ import {
IconTerminal2
} from '@tabler/icons';
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
import { importCollection, openCollection, importCollectionFromZip, newHttpRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import { normalizePath } from 'utils/common/path';
import { isScratchCollection } from 'utils/collections';
import { isScratchCollection, flattenItems, isItemTransientRequest } from 'utils/collections';
import { sanitizeName } from 'utils/common/regex';
import filter from 'lodash/filter';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
@@ -28,6 +32,7 @@ import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectio
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
import RemoveCollectionsModal from 'components/Sidebar/Collections/RemoveCollectionsModal/index';
import CreateCollection from 'components/Sidebar/CreateCollection';
import WelcomeModal from 'components/WelcomeModal';
import Collections from 'components/Sidebar/Collections';
import SidebarSection from 'components/Sidebar/SidebarSection';
import { openDevtoolsAndSwitchToTerminal } from 'utils/terminal';
@@ -41,6 +46,7 @@ const CollectionsSection = () => {
const { collections } = useSelector((state) => state.collections);
const { collectionSortOrder } = useSelector((state) => state.collections);
const preferences = useSelector((state) => state.app.preferences);
const [collectionsToClose, setCollectionsToClose] = useState([]);
const [importData, setImportData] = useState(null);
@@ -50,6 +56,42 @@ const CollectionsSection = () => {
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
// Listen for sidebar-search-open hotkey event
useEffect(() => {
const handleSidebarSearch = () => {
setShowSearch(true);
// Focus the search input after it's rendered
setTimeout(() => {
const searchInput = document.querySelector('.collection-search-input');
if (searchInput) {
searchInput.focus();
}
}, 50);
};
window.addEventListener('sidebar-search-open', handleSidebarSearch);
return () => window.removeEventListener('sidebar-search-open', handleSidebarSearch);
}, []);
// Default to true (don't show modal) so that:
// 1. Existing users who upgrade (no hasSeenWelcomeModal in their prefs) don't see it
// 2. The modal doesn't flash before preferences are loaded from the electron process
// Only genuinely new users will have hasSeenWelcomeModal explicitly set to false by onboarding
const hasSeenWelcomeModal = get(preferences, 'onboarding.hasSeenWelcomeModal', true);
const showWelcomeModal = !hasSeenWelcomeModal;
const handleDismissWelcomeModal = () => {
const updatedPreferences = {
...preferences,
onboarding: {
...preferences.onboarding,
hasSeenWelcomeModal: true
}
};
dispatch(savePreferences(updatedPreferences)).catch(() => {
toast.error('Failed to save preferences');
});
};
const workspaceCollections = useMemo(() => {
if (!activeWorkspace) return [];
@@ -155,6 +197,50 @@ const CollectionsSection = () => {
});
};
const handleStartRequest = () => {
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
if (!scratchCollectionUid) {
toast.error('Unable to create request');
return;
}
const scratchCollection = collections.find((c) => c.uid === scratchCollectionUid);
if (!scratchCollection) {
toast.error('Unable to create request');
return;
}
const allItems = flattenItems(scratchCollection.items || []);
const transientRequests = filter(allItems, (item) => isItemTransientRequest(item));
let maxNumber = 0;
transientRequests.forEach((item) => {
const match = item.name?.match(/^Untitled (\d+)$/);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
});
const requestName = `Untitled ${maxNumber + 1}`;
const filename = sanitizeName(requestName);
dispatch(
newHttpRequest({
requestName,
filename,
requestType: 'http-request',
requestUrl: '',
requestMethod: 'GET',
collectionUid: scratchCollectionUid,
itemUid: null,
isTransient: true
})
).catch((err) => {
toast.error('An error occurred while creating the request');
});
};
const addDropdownItems = [
{
id: 'create',
@@ -250,6 +336,27 @@ const CollectionsSection = () => {
return (
<>
{showWelcomeModal && (
<WelcomeModal
onDismiss={handleDismissWelcomeModal}
onImportCollection={() => {
handleDismissWelcomeModal();
setImportCollectionModalOpen(true);
}}
onCreateCollection={() => {
handleDismissWelcomeModal();
setCreateCollectionModalOpen(true);
}}
onOpenCollection={() => {
handleDismissWelcomeModal();
handleOpenCollection();
}}
onStartRequest={() => {
handleDismissWelcomeModal();
handleStartRequest();
}}
/>
)}
{createCollectionModalOpen && (
<CreateCollection
onClose={() => setCreateCollectionModalOpen(false)}

View File

@@ -7,6 +7,7 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import { IconEye, IconEyeOff } from '@tabler/icons';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
const CodeMirror = require('codemirror');
@@ -21,8 +22,11 @@ class SingleLineEditor extends Component {
this.variables = {};
this.readOnly = props.readOnly || false;
// Shortcuts cleanup function
this._shortcutsCleanup = null;
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
maskInput: props.isSecret || false
};
}
@@ -59,8 +63,8 @@ class SingleLineEditor extends Component {
readOnly: this.props.readOnly,
extraKeys: {
'Enter': runHandler,
'Ctrl-Enter': runHandler,
'Cmd-Enter': runHandler,
// 'Ctrl-Enter': runHandler,
// 'Cmd-Enter': runHandler,
'Alt-Enter': () => {
if (this.props.allowNewlines) {
this.editor.setValue(this.editor.getValue() + '\n');
@@ -69,7 +73,7 @@ class SingleLineEditor extends Component {
this.props.onRun();
}
},
'Shift-Enter': runHandler,
// 'Shift-Enter': runHandler,
'Cmd-S': saveHandler,
'Ctrl-S': saveHandler,
'Cmd-F': noopHandler,
@@ -108,6 +112,9 @@ class SingleLineEditor extends Component {
this._updateNewlineMarkers();
}
setupLinkAware(this.editor);
// Setup keyboard shortcuts using the dedicated utility
this._shortcutsCleanup = setupShortcuts(this.editor, this);
}
/** Enable or disable masking the rendered content of the editor */
@@ -172,7 +179,7 @@ class SingleLineEditor extends Component {
// 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) {
if (this.editor.hasFocus?.() && currentValue !== nextValue && nextValue !== '') {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
@@ -202,6 +209,12 @@ class SingleLineEditor extends Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.editor) {
if (this.editor?._destroyLinkAware) {
this.editor._destroyLinkAware();

View File

@@ -49,10 +49,7 @@ const StatusBar = () => {
};
const openGlobalSearch = () => {
const bindings = getKeyBindingsForActionAllOS('globalSearch') || [];
bindings.forEach((binding) => {
Mousetrap.trigger(binding);
});
window.dispatchEvent(new CustomEvent('global-search-open'));
};
return (

View File

@@ -7,7 +7,7 @@ import { useTheme } from 'providers/Theme/index';
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation, collectionFormat }) => {
const { displayedTheme } = useTheme();
const isBruFormat = collectionFormat === 'bru';
const tagNameRegex = isBruFormat ? /^[\w-]+$/ : /^[\w-][\w\s-]*[\w-]$|^[\w-]+$/;
const tagNameRegex = isBruFormat ? /^[\p{L}\p{N}_-]+$/u : /^[\p{L}\p{N}_-](?:[\p{L}\p{N}_\s-]*[\p{L}\p{N}_-])?$/u;
const [text, setText] = useState('');
const [error, setError] = useState('');
@@ -22,8 +22,8 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
}
if (!tagNameRegex.test(text)) {
setError(isBruFormat
? 'Tags in BRU format must only contain alpha-numeric characters, "-", "_".'
: 'Tags must only contain alpha-numeric characters, spaces, "-", "_"'
? 'Tags in BRU format must only contain letters, numbers, "-", "_".'
: 'Tags must only contain letters, numbers, spaces, "-", "_"'
);
return;
}

View File

@@ -99,6 +99,8 @@ const VariablesEditor = ({ collection }) => {
<div className="mt-8 muted text-xs">
Note: As of today, runtime variables can only be set via the API - <span className="font-medium">getVar()</span>{' '}
and <span className="font-medium">setVar()</span>. <br />
You can use the <span className="font-medium">var</span> variable with the
<span className="font-medium">{'{{var}}'}</span> syntax.<br />
</div>
</StyledWrapper>
);

View File

@@ -0,0 +1,107 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.primary-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.primary-action-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem 1rem;
border-radius: ${(props) => props.theme.border.radius.md};
border: 1px solid ${(props) => props.theme.border.border1};
background: transparent;
cursor: pointer;
text-align: center;
color: ${(props) => props.theme.text};
transition: all 0.15s ease;
&:hover {
border-color: ${(props) => props.theme.primary.subtle};
background: ${(props) => rgba(props.theme.primary.solid, 0.06)};
}
&:active {
transform: scale(0.98);
}
.card-icon {
width: 40px;
height: 40px;
border-radius: ${(props) => props.theme.border.radius.md};
display: flex;
align-items: center;
justify-content: center;
background: ${(props) => rgba(props.theme.primary.solid, 0.1)};
color: ${(props) => props.theme.primary.solid};
}
.card-title {
font-weight: 600;
font-size: 0.875rem;
}
.card-desc {
font-size: 0.75rem;
color: ${(props) => props.theme.colors.text.subtext0};
line-height: 1.4;
}
}
.secondary-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.secondary-action {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: ${(props) => props.theme.border.radius.base};
border: 1px solid ${(props) => props.theme.border.border0};
background: transparent;
cursor: pointer;
text-align: left;
width: 100%;
color: ${(props) => props.theme.text};
transition: all 0.15s ease;
&:hover {
border-color: ${(props) => props.theme.primary.subtle};
background: ${(props) => rgba(props.theme.primary.solid, 0.06)};
}
&:active {
transform: scale(0.98);
}
.secondary-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: ${(props) => props.theme.colors.text.subtext0};
}
.secondary-label {
font-size: 0.8125rem;
font-weight: 500;
}
.secondary-desc {
font-size: 0.6875rem;
color: ${(props) => props.theme.colors.text.subtext0};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { IconPlus, IconDownload, IconFileImport, IconSend } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const GetStartedStep = ({ onCreateCollection, onImportCollection, onOpenCollection, onStartRequest }) => (
<StyledWrapper className="step-body">
<div className="step-label">Your first collection</div>
<div className="step-title">You're all set! What's next?</div>
<div className="step-description">
Create a new collection to start building requests, or import one you already have.
</div>
<div className="primary-actions">
<button className="primary-action-card" onClick={onCreateCollection}>
<div className="card-icon">
<IconPlus size={20} stroke={1.5} />
</div>
<div className="card-title">Create Collection</div>
<div className="card-desc">Start fresh with a new API collection</div>
</button>
<button className="primary-action-card" onClick={onImportCollection}>
<div className="card-icon">
<IconDownload size={20} stroke={1.5} />
</div>
<div className="card-title">Import Collection</div>
<div className="card-desc">Bring in Postman, OpenAPI, or Insomnia</div>
</button>
</div>
<div className="secondary-actions">
<button className="secondary-action" onClick={onOpenCollection}>
<span className="secondary-icon">
<IconFileImport size={16} stroke={1.5} />
</span>
<div>
<div className="secondary-label">Open existing collection</div>
<div className="secondary-desc">Open a Bruno collection from your filesystem</div>
</div>
</button>
<button className="secondary-action" onClick={onStartRequest}>
<span className="secondary-icon">
<IconSend size={16} stroke={1.5} />
</span>
<div>
<div className="secondary-label">Get started with a request</div>
<div className="secondary-desc">Jump right in with a new HTTP request</div>
</div>
</button>
</div>
</StyledWrapper>
);
export default GetStartedStep;

View File

@@ -0,0 +1,55 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.location-input-group {
margin-bottom: 0.5rem;
}
.location-path-display {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0.75rem;
border-radius: ${(props) => props.theme.border.radius.base};
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
font-size: 0.8125rem;
line-height: 1.42857143;
cursor: pointer;
transition: border-color 0.15s ease;
gap: 0.625rem;
min-height: 38px;
&:hover {
border-color: ${(props) => props.theme.input.focusBorder};
}
.path-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path-placeholder {
color: ${(props) => props.theme.colors.text.subtext0};
}
.browse-label {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 500;
color: ${(props) => props.theme.primary.text};
}
}
.location-hint {
color: ${(props) => props.theme.colors.text.subtext0};
font-size: 0.75rem;
line-height: 1.4;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import StyledWrapper from './StyledWrapper';
const StorageStep = ({ collectionLocation, onBrowse }) => (
<StyledWrapper className="step-body">
<div className="step-label">Storage</div>
<div className="step-title">Where should we store your collections?</div>
<div className="step-description">
Bruno saves collections as plain files on your filesystem, perfect for version control with Git.
</div>
<div className="location-input-group">
<div
className="location-path-display"
onClick={onBrowse}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onBrowse();
}
}}
role="button"
tabIndex={0}
>
{collectionLocation ? (
<span className="path-text">{collectionLocation}</span>
) : (
<span className="path-text path-placeholder">Click to choose a folder...</span>
)}
<span className="browse-label">Browse</span>
</div>
</div>
<div className="location-hint">
Each collection and workspace gets its own folder inside this directory. You can change this later.
</div>
</StyledWrapper>
);
export default StorageStep;

View File

@@ -0,0 +1,131 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.55);
.welcome-card {
background: ${(props) => props.theme.modal.body.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.xl};
box-shadow: ${(props) => props.theme.shadow.lg};
width: 660px;
max-width: 92vw;
max-height: 90vh;
overflow-y: auto;
animation: welcomeSlideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes welcomeSlideIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.welcome-header {
text-align: center;
padding: 2.25rem 2.5rem 0 2.5rem;
}
.logo-container {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 0.75rem;
}
.welcome-heading {
font-size: 1.375rem;
font-weight: 700;
color: ${(props) => props.theme.text};
margin: 0;
line-height: 1.3;
}
.welcome-tagline {
color: ${(props) => props.theme.colors.text.subtext1};
font-size: 0.875rem;
margin-top: 0.25rem;
line-height: 1.5;
}
.step-body {
padding: 1.5rem 2.5rem;
}
.step-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: ${(props) => props.theme.primary.text};
margin-bottom: 0.375rem;
}
.step-title {
font-size: 1.05rem;
font-weight: 600;
color: ${(props) => props.theme.text};
margin-bottom: 0.25rem;
}
.step-description {
color: ${(props) => props.theme.colors.text.subtext1};
font-size: 0.8125rem;
line-height: 1.5;
margin-bottom: 1.25rem;
}
.welcome-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 2.5rem 1.75rem 2.5rem;
}
.progress-dots {
display: flex;
gap: 6px;
align-items: center;
.dot {
width: 8px;
height: 8px;
padding: 0;
border: none;
border-radius: 50%;
background: ${(props) => props.theme.border.border2};
transition: all 0.25s ease;
cursor: pointer;
&.active {
background: ${(props) => props.theme.primary.solid};
width: 20px;
border-radius: 4px;
}
&.completed {
background: ${(props) => rgba(props.theme.primary.solid, 0.45)};
}
}
}
.footer-buttons {
display: flex;
align-items: center;
gap: 0.5rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,105 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.theme-mode-buttons {
display: flex;
gap: 0.5rem;
margin-bottom: 1.25rem;
}
.theme-mode-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: ${(props) => props.theme.border.radius.md};
border: 1.5px solid ${(props) => props.theme.border.border1};
background: transparent;
color: ${(props) => props.theme.colors.text.subtext1};
cursor: pointer;
font-size: 0.8125rem;
font-weight: 500;
transition: all 0.15s ease;
&:hover {
border-color: ${(props) => props.theme.border.border2};
color: ${(props) => props.theme.text};
}
&.active {
border-color: ${(props) => props.theme.primary.solid};
background: ${(props) => rgba(props.theme.primary.solid, 0.07)};
color: ${(props) => props.theme.text};
}
}
.theme-variants-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(105px, 1fr));
gap: 0.5rem;
}
.theme-variant-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.375rem;
border-radius: ${(props) => props.theme.border.radius.base};
border: 1.5px solid ${(props) => props.theme.border.border0};
background: transparent;
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
&:hover {
border-color: ${(props) => props.theme.border.border2};
}
&.selected {
border-color: ${(props) => props.theme.primary.solid};
background: ${(props) => rgba(props.theme.primary.solid, 0.06)};
}
.variant-name {
font-size: 0.6875rem;
color: ${(props) => props.theme.colors.text.subtext0};
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
}
.theme-preview-box {
width: 52px;
height: 34px;
border-radius: 3px;
display: flex;
overflow: hidden;
.preview-sidebar {
width: 13px;
height: 100%;
}
.preview-main {
flex: 1;
display: flex;
flex-direction: column;
padding: 4px;
gap: 3px;
}
.preview-line {
height: 3px;
border-radius: 2px;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { rgba } from 'polished';
import { IconBrightnessUp, IconMoon, IconDeviceDesktop } from '@tabler/icons';
import themes, { getLightThemes, getDarkThemes } from 'themes/index';
import StyledWrapper from './StyledWrapper';
const themeModes = [
{ key: 'light', label: 'Light', icon: IconBrightnessUp },
{ key: 'dark', label: 'Dark', icon: IconMoon },
{ key: 'system', label: 'System', icon: IconDeviceDesktop }
];
const ThemePreviewBox = ({ themeId, isDark }) => {
const themeData = themes[themeId] || themes[isDark ? 'dark' : 'light'];
const bgColor = themeData.background.base;
const sidebarColor = themeData.sidebar.bg;
const lineColor = rgba(themeData.brand, 0.5);
return (
<div className="theme-preview-box" style={{ background: bgColor, border: `1px solid ${lineColor}` }}>
<div className="preview-sidebar" style={{ background: sidebarColor }} />
<div className="preview-main">
<div className="preview-line" style={{ background: lineColor, width: '80%' }} />
<div className="preview-line" style={{ background: lineColor, width: '55%' }} />
<div className="preview-line" style={{ background: lineColor, width: '70%' }} />
</div>
</div>
);
};
const ThemeStep = ({ storedTheme, setStoredTheme, themeVariantLight, setThemeVariantLight, themeVariantDark, setThemeVariantDark }) => {
const lightThemeList = getLightThemes();
const darkThemeList = getDarkThemes();
const showLight = storedTheme === 'light' || storedTheme === 'system';
const showDark = storedTheme === 'dark' || storedTheme === 'system';
return (
<StyledWrapper className="step-body">
<div className="step-label">Appearance</div>
<div className="step-title">Choose your theme</div>
<div className="step-description">
Pick a look that feels right. You can always change this later in Preferences.
</div>
<div className="theme-mode-buttons">
{themeModes.map((mode) => {
const Icon = mode.icon;
return (
<button
key={mode.key}
className={`theme-mode-btn ${storedTheme === mode.key ? 'active' : ''}`}
onClick={() => setStoredTheme(mode.key)}
>
<Icon size={16} stroke={1.5} />
{mode.label}
</button>
);
})}
</div>
{showLight && (
<div className="theme-variants-grid" style={{ marginBottom: showDark ? '1rem' : 0 }}>
{lightThemeList.map((t) => (
<button
type="button"
key={t.id}
className={`theme-variant-option ${themeVariantLight === t.id ? 'selected' : ''}`}
onClick={() => setThemeVariantLight(t.id)}
aria-pressed={themeVariantLight === t.id}
>
<ThemePreviewBox themeId={t.id} isDark={false} />
<span className="variant-name">{t.name}</span>
</button>
))}
</div>
)}
{showDark && (
<div className="theme-variants-grid">
{darkThemeList.map((t) => (
<button
type="button"
key={t.id}
className={`theme-variant-option ${themeVariantDark === t.id ? 'selected' : ''}`}
onClick={() => setThemeVariantDark(t.id)}
aria-pressed={themeVariantDark === t.id}
>
<ThemePreviewBox themeId={t.id} isDark={true} />
<span className="variant-name">{t.name}</span>
</button>
))}
</div>
)}
</StyledWrapper>
);
};
export default ThemeStep;

View File

@@ -0,0 +1,44 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.highlights {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.highlight-item {
display: flex;
align-items: flex-start;
gap: 0.875rem;
.highlight-icon {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: ${(props) => props.theme.border.radius.base};
display: flex;
align-items: center;
justify-content: center;
background: ${(props) => rgba(props.theme.primary.solid, 0.1)};
color: ${(props) => props.theme.primary.solid};
margin-top: 1px;
}
.highlight-title {
font-weight: 600;
font-size: 0.8125rem;
color: ${(props) => props.theme.text};
margin-bottom: 0.125rem;
}
.highlight-desc {
font-size: 0.75rem;
color: ${(props) => props.theme.colors.text.subtext1};
line-height: 1.45;
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,54 @@
import React from 'react';
import {
IconFolder as IconFolderTabler,
IconGitFork,
IconLock,
IconRocket
} from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const highlights = [
{
icon: IconFolderTabler,
title: 'Filesystem only',
desc: 'Collections are plain files on your disk. No cloud sync, no proprietary lock-in.'
},
{
icon: IconGitFork,
title: 'Git-friendly',
desc: 'Every request is a readable file. Commit, branch, review, and collaborate using the tools you already know.'
},
{
icon: IconLock,
title: 'Privacy-focused',
desc: 'No account, no login. Bruno works entirely offline, your API keys never leave your machine.'
},
{
icon: IconRocket,
title: 'Fast and lightweight',
desc: 'Built to be snappy. No bloated runtimes, just a fast, focused tool for exploring and testing APIs.'
}
];
const WelcomeStep = () => (
<StyledWrapper className="step-body">
<div className="highlights">
{highlights.map((item) => {
const Icon = item.icon;
return (
<div key={item.title} className="highlight-item">
<div className="highlight-icon">
<Icon size={18} stroke={1.5} />
</div>
<div>
<div className="highlight-title">{item.title}</div>
<div className="highlight-desc">{item.desc}</div>
</div>
</div>
);
})}
</div>
</StyledWrapper>
);
export default WelcomeStep;

View File

@@ -0,0 +1,161 @@
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import get from 'lodash/get';
import toast from 'react-hot-toast';
import Bruno from 'components/Bruno';
import Button from 'ui/Button';
import { useTheme } from 'providers/Theme';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import WelcomeStep from './WelcomeStep';
import ThemeStep from './ThemeStep';
import StorageStep from './StorageStep';
import GetStartedStep from './GetStartedStep';
import StyledWrapper from './StyledWrapper';
const TOTAL_STEPS = 4;
const WelcomeModal = ({ onDismiss, onImportCollection, onCreateCollection, onOpenCollection, onStartRequest }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const defaultLocation = get(preferences, 'general.defaultLocation', '');
const {
storedTheme,
setStoredTheme,
themeVariantLight,
setThemeVariantLight,
themeVariantDark,
setThemeVariantDark
} = useTheme();
const [step, setStep] = useState(1);
const [collectionLocation, setCollectionLocation] = useState(defaultLocation);
const handleBrowse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
setCollectionLocation(dirPath);
}
})
.catch(() => {});
};
const persistPreferences = () => {
if (collectionLocation && collectionLocation !== defaultLocation) {
const updatedPreferences = {
...preferences,
general: {
...preferences.general,
defaultLocation: collectionLocation
}
};
return dispatch(savePreferences(updatedPreferences)).catch(() => {
toast.error('Failed to save preferences');
});
}
return Promise.resolve();
};
const handleSaveAndDismiss = () => {
persistPreferences().finally(() => {
onDismiss();
});
};
const handleActionAndDismiss = (action) => () => {
persistPreferences().finally(() => {
onDismiss();
action();
});
};
const goTo = (s) => setStep(s);
const steps = [
<WelcomeStep key="welcome" />,
<ThemeStep
key="theme"
storedTheme={storedTheme}
setStoredTheme={setStoredTheme}
themeVariantLight={themeVariantLight}
setThemeVariantLight={setThemeVariantLight}
themeVariantDark={themeVariantDark}
setThemeVariantDark={setThemeVariantDark}
/>,
<StorageStep
key="storage"
collectionLocation={collectionLocation}
onBrowse={handleBrowse}
/>,
<GetStartedStep
key="getstarted"
onCreateCollection={handleActionAndDismiss(onCreateCollection)}
onImportCollection={handleActionAndDismiss(onImportCollection)}
onOpenCollection={handleActionAndDismiss(onOpenCollection)}
onStartRequest={handleActionAndDismiss(onStartRequest)}
/>
];
const isLastStep = step === TOTAL_STEPS;
return (
<StyledWrapper data-testid="welcome-modal">
<div className="welcome-card">
<div className="welcome-header">
<div className="logo-container">
<Bruno width={48} />
</div>
<h1 className="welcome-heading">
{step === 1 ? 'Welcome to Bruno' : step === 4 ? 'Ready to go!' : 'Set up Bruno'}
</h1>
{step === 1 && (
<p className="welcome-tagline">
A fast, Git-friendly, and open-source API client.
</p>
)}
</div>
{steps[step - 1]}
<div className="welcome-footer">
<div className="progress-dots">
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
<button
type="button"
key={i}
className={`dot ${i + 1 === step ? 'active' : ''} ${i + 1 < step ? 'completed' : ''}`}
onClick={() => goTo(i + 1)}
aria-label={`Go to step ${i + 1}`}
aria-current={i + 1 === step ? 'step' : undefined}
/>
))}
</div>
<div className="footer-buttons">
<Button type="button" color="secondary" variant="ghost" onClick={handleSaveAndDismiss}>
Skip
</Button>
{step > 1 && (
<Button type="button" color="secondary" variant="ghost" onClick={() => goTo(step - 1)}>
Back
</Button>
)}
{!isLastStep && (
<Button type="button" onClick={() => goTo(step + 1)}>
{step === 1 ? 'Get Started' : 'Next'}
</Button>
)}
{isLastStep && (
<Button type="button" color="secondary" onClick={handleSaveAndDismiss}>
I'll explore on my own
</Button>
)}
</div>
</div>
</div>
</StyledWrapper>
);
};
export default WelcomeModal;

View File

@@ -1,7 +1,6 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments';
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 globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
@@ -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() === '') {

View File

@@ -32,19 +32,7 @@ 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 +54,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 +137,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';
@@ -20,6 +21,7 @@ import {
createWorkspaceDotEnvFile,
deleteWorkspaceDotEnvFile
} from 'providers/ReduxStore/slices/workspaces/actions';
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 +41,15 @@ const EnvironmentList = ({
}) => {
const dispatch = useDispatch();
const globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.global?.query ?? '');
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.global?.expanded ?? false);
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'global', query: q }));
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'global', 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 +72,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 ws = state.workspaces.workspaces.find((w) => w.uid === workspace?.uid);
return ws?.dotEnvFiles || EMPTY_ARRAY;
@@ -493,6 +504,12 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -525,20 +542,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
@@ -547,6 +550,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>
@@ -559,6 +575,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

@@ -1,8 +1,9 @@
import React, { useState, useMemo, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX } from '@tabler/icons';
import { IconBox, IconTrash, IconEdit, IconShare, IconDots, IconX, IconFolder } from '@tabler/icons';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { mountCollection, showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { normalizePath } from 'utils/common/path';
import toast from 'react-hot-toast';
import RenameCollection from 'components/Sidebar/Collections/Collection/RenameCollection';
@@ -153,6 +154,14 @@ const CollectionsList = ({ workspace }) => {
setDeleteCollectionModalOpen(true);
};
const handleShowInFolder = (collection) => {
dropdownRefs.current[collection.uid]?.hide();
dispatch(showInFolder(collection.pathname)).catch((error) => {
console.error('Error opening the folder', error);
toast.error('Error opening the folder');
});
};
return (
<StyledWrapper>
{renameCollectionModalOpen && selectedCollectionUid && (
@@ -201,9 +210,7 @@ const CollectionsList = ({ workspace }) => {
<div className="empty-state">
<IconBox size={32} strokeWidth={1.5} className="empty-icon" />
<h3 className="empty-title">No collections yet</h3>
<p className="empty-description">
Create your first collection or open an existing one to get started.
</p>
<p className="empty-description">Create your first collection or open an existing one to get started.</p>
</div>
) : (
workspaceCollections.map((collection, index) => (
@@ -249,6 +256,16 @@ const CollectionsList = ({ workspace }) => {
<IconShare size={16} strokeWidth={1.5} />
<span>Share</span>
</div>
<div
className="dropdown-item"
onClick={(e) => {
e.stopPropagation();
handleShowInFolder(collection);
}}
>
<IconFolder size={16} strokeWidth={1.5} />
<span>{getRevealInFolderLabel()}</span>
</div>
<div
className="dropdown-item"
onClick={(e) => {

View File

@@ -12,20 +12,24 @@ import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions
import { multiLineMsg } from 'utils/common/index';
import { formatIpcError } from 'utils/common/error';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import get from 'lodash/get';
const CreateWorkspace = ({ onClose }) => {
const inputRef = useRef();
const dispatch = useDispatch();
const workspaces = useSelector((state) => state.workspaces.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const defaultLocation = get(preferences, 'general.defaultLocation', '');
const formik = useFormik({
enableReinitialize: true,
initialValues: {
workspaceName: '',
workspaceFolderName: '',
workspaceLocation: ''
workspaceLocation: defaultLocation
},
validationSchema: Yup.object({
workspaceName: Yup.string()

View File

@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { IconFileZip } from '@tabler/icons';
import Modal from 'components/Modal';
import { browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
@@ -13,16 +14,19 @@ import Help from 'components/Help';
const ImportWorkspace = ({ onClose }) => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const [dragActive, setDragActive] = useState(false);
const [selectedFile, setSelectedFile] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const fileInputRef = useRef(null);
const locationInputRef = useRef(null);
const defaultLocation = get(preferences, 'general.defaultLocation', '');
const formik = useFormik({
enableReinitialize: true,
initialValues: {
workspaceLocation: ''
workspaceLocation: defaultLocation
},
validationSchema: Yup.object({
workspaceLocation: Yup.string().min(1, 'location is required').required('location is required')

View File

@@ -395,11 +395,12 @@ const GlobalStyle = createGlobalStyle`
font-size: ${(props) => props.theme.font.size.base};
font-family: Inter, sans-serif;
font-weight: 400;
word-break: break-word;
overflow-wrap: break-word;
white-space: pre-wrap;
line-height: 1.25rem;
color: ${(props) => props.theme.dropdown.color};
min-height: 1.75rem;
max-width: 13.1875rem;
max-width: 17.1875rem;
}
/* Value Editor (CodeMirror) */

View File

@@ -218,7 +218,7 @@ const SaveRequestsModal = ({ onClose }) => {
</Button>
</div>
<div className="flex gap-2">
<Button size="sm" color="secondary" variant="ghost" onClick={onClose}>
<Button color="secondary" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button onClick={closeWithSave}>

View File

@@ -5,6 +5,7 @@ import { refreshScreenWidth } from 'providers/ReduxStore/slices/app';
import ConfirmAppClose from './ConfirmAppClose';
import useIpcEvents from './useIpcEvents';
import useTelemetry from './useTelemetry';
import useParsedFileCacheIpc from './useParsedFileCacheIpc';
import StyledWrapper from './StyledWrapper';
import { version } from '../../../package.json';
@@ -13,6 +14,7 @@ export const AppContext = React.createContext();
export const AppProvider = (props) => {
useTelemetry({ version });
useIpcEvents();
useParsedFileCacheIpc();
const dispatch = useDispatch();
useEffect(() => {

View File

@@ -11,6 +11,7 @@ import {
brunoConfigUpdateEvent,
collectionAddDirectoryEvent,
collectionAddFileEvent,
collectionBatchAddItems,
collectionChangeFileEvent,
collectionRenamedEvent,
collectionUnlinkDirectoryEvent,
@@ -35,7 +36,7 @@ import toast from 'react-hot-toast';
import { useDispatch, useStore } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
import { collectionAddOauth2CredentialsByUrl, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { addLog } from 'providers/ReduxStore/slices/logs';
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
import { apiSpecAddFileEvent, apiSpecChangeFileEvent } from 'providers/ReduxStore/slices/apiSpec';
@@ -101,6 +102,50 @@ const useIpcEvents = () => {
}
};
// Batch handler for collection tree updates (performance optimization)
// Uses a single Redux dispatch to process all items, avoiding multiple re-renders
const _collectionTreeBatchUpdated = (batch) => {
if (!batch || !Array.isArray(batch) || batch.length === 0) {
return;
}
if (window.__IS_DEV__) {
console.log('Batch update received:', batch.length, 'items');
}
// Separate batch items into those that can be bulk-processed vs those that need individual handling
const bulkItems = []; // addFile, addDir - can be processed in single reducer
const individualItems = []; // change, unlink, etc - need individual dispatches
batch.forEach(({ eventType, payload }) => {
if (eventType === 'addDir' || eventType === 'addFile') {
bulkItems.push({ eventType, payload });
} else {
individualItems.push({ eventType, payload });
}
});
// Process bulk items in a single dispatch (addFile and addDir)
if (bulkItems.length > 0) {
dispatch(collectionBatchAddItems({ items: bulkItems }));
}
// Process remaining items individually (these are typically rare during mount)
individualItems.forEach(({ eventType, payload }) => {
if (eventType === 'change') {
dispatch(collectionChangeFileEvent({ file: payload }));
} else if (eventType === 'unlink') {
dispatch(collectionUnlinkFileEvent({ file: payload }));
} else if (eventType === 'unlinkDir') {
dispatch(collectionUnlinkDirectoryEvent({ directory: payload }));
} else if (eventType === 'addEnvironmentFile') {
dispatch(collectionAddEnvFileEvent(payload));
} else if (eventType === 'unlinkEnvironmentFile') {
dispatch(collectionUnlinkEnvFileEvent(payload));
}
});
};
const _apiSpecTreeUpdated = (type, val) => {
if (window.__IS_DEV__) {
console.log('API Spec update:', type);
@@ -118,6 +163,8 @@ const useIpcEvents = () => {
const removeCollectionTreeUpdateListener = ipcRenderer.on('main:collection-tree-updated', _collectionTreeUpdated);
const removeCollectionTreeBatchUpdateListener = ipcRenderer.on('main:collection-tree-batch-updated', _collectionTreeBatchUpdated);
const removeApiSpecTreeUpdateListener = ipcRenderer.on('main:apispec-tree-updated', _apiSpecTreeUpdated);
const removeOpenCollectionListener = ipcRenderer.on('main:collection-opened', (pathname, uid, brunoConfig) => {
@@ -318,6 +365,10 @@ const useIpcEvents = () => {
dispatch(collectionAddOauth2CredentialsByUrl(payload));
});
const removeCollectionOauth2CredentialsClearListener = ipcRenderer.on('main:credentials-clear', (val) => {
dispatch(collectionClearOauth2CredentialsByCredentialsId(val));
});
const removeHttpStreamNewDataListener = ipcRenderer.on('main:http-stream-new-data', (val) => {
dispatch(streamDataReceived(val));
});
@@ -336,6 +387,7 @@ const useIpcEvents = () => {
return () => {
removeCollectionTreeUpdateListener();
removeCollectionTreeBatchUpdateListener();
removeApiSpecTreeUpdateListener();
removeOpenCollectionListener();
removeOpenWorkspaceListener();
@@ -360,6 +412,7 @@ const useIpcEvents = () => {
removeGlobalEnvironmentsUpdatesListener();
removeSnapshotHydrationListener();
removeCollectionOauth2CredentialsUpdatesListener();
removeCollectionOauth2CredentialsClearListener();
removeHttpStreamNewDataListener();
removeHttpStreamEndListener();
removeCollectionLoadingStateListener();

View File

@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { isElectron } from 'utils/common/platform';
import { parsedFileCacheStore } from 'store/parsedFileCache';
const useParsedFileCacheIpc = () => {
useEffect(() => {
if (!isElectron()) {
return () => {};
}
const { ipcRenderer } = window;
const handleCacheRequest = async (operation, requestId, ...args) => {
try {
let result = null;
switch (operation) {
case 'getEntry':
result = await parsedFileCacheStore.getEntry(...args);
break;
case 'setEntry':
await parsedFileCacheStore.setEntry(...args);
break;
case 'invalidate':
await parsedFileCacheStore.invalidate(...args);
break;
case 'invalidateCollection':
await parsedFileCacheStore.invalidateCollection(...args);
break;
case 'invalidateDirectory':
await parsedFileCacheStore.invalidateDirectory(...args);
break;
case 'getStats':
result = await parsedFileCacheStore.getStats();
break;
case 'clear':
await parsedFileCacheStore.clear();
break;
default:
throw new Error(`Unknown cache operation: ${operation}`);
}
ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: true, data: result });
} catch (error) {
ipcRenderer.send('renderer:parsed-file-cache-response', { requestId, success: false, error: error.message });
}
};
const removeListener = ipcRenderer.on('main:parsed-file-cache-request', handleCacheRequest);
// Prune old cache entries on startup
parsedFileCacheStore.prune().catch((err) => {
console.error('ParsedFileCacheStore: Error during startup prune:', err);
});
return () => {
removeListener();
};
}, []);
};
export default useParsedFileCacheIpc;

View File

@@ -1,290 +1,366 @@
import React, { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import React, { createContext, useEffect, useContext, useRef, useState } from 'react';
import find from 'lodash/find';
import Mousetrap from 'mousetrap';
import { useSelector, useDispatch } from 'react-redux';
import NetworkError from 'components/ResponsePane/NetworkError';
import toast from 'react-hot-toast';
import { useSelector } from 'react-redux';
import NewRequest from 'components/Sidebar/NewRequest';
import NetworkError from 'components/ResponsePane/NetworkError';
import GlobalSearchModal from 'components/GlobalSearchModal';
import ImportCollection from 'components/Sidebar/ImportCollection';
import store from 'providers/ReduxStore/index';
import {
sendRequest,
saveRequest,
saveCollectionRoot,
saveFolderRoot,
saveCollectionSettings,
closeTabs
closeTabs,
cloneItem,
pasteItem
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { savePreferences, toggleSidebarCollapse, copyRequest } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
export const HotkeysContext = React.createContext();
export const HotkeysContext = createContext(null);
// List of all actions that are bound in this provider
const BOUND_ACTIONS = [
'save',
'sendRequest',
'editEnvironment',
'newRequest',
'globalSearch',
'closeTab',
'switchToPreviousTab',
'switchToNextTab',
'closeAllTabs',
'collapseSidebar',
'moveTabLeft',
'moveTabRight',
'changeLayout',
'closeBruno',
'openPreferences',
'importCollection',
'sidebarSearch',
'zoomIn',
'zoomOut',
'resetZoom',
'cloneItem',
'copyItem',
'pasteItem',
'renameItem'
];
/**
* Bind a single hotkey action using Mousetrap.
* Reads from merged defaults + user preferences via getKeyBindingsForActionAllOS.
*/
function bindHotkey(action, handler, userKeyBindings) {
const combos = getKeyBindingsForActionAllOS(action, userKeyBindings);
if (!combos?.length) return;
Mousetrap.bind([...combos], (e) => {
e?.preventDefault?.();
handler(e);
return false;
});
}
/**
* Unbind a single hotkey action.
*/
function unbindHotkey(action, userKeyBindings) {
const combos = getKeyBindingsForActionAllOS(action, userKeyBindings);
if (!combos?.length) return;
Mousetrap.unbind([...combos]);
}
/**
* Unbind all known actions for the given user key bindings.
*/
function unbindAllHotkeys(userKeyBindings) {
BOUND_ACTIONS.forEach((action) => unbindHotkey(action, userKeyBindings));
}
/**
* Bind all hotkey actions.
*/
function bindAllHotkeys(userKeyBindings) {
const { dispatch, getState } = store;
// SAVE
bindHotkey('save', () => {
const state = getState();
const tabs = state.tabs.tabs;
const collections = state.collections.collections;
const activeTabUid = state.tabs.activeTabUid;
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (!activeTab) return;
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
window.dispatchEvent(new CustomEvent('environment-save'));
return;
}
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, activeTab.uid);
if (item?.uid) {
if (activeTab.type === 'folder-settings') {
dispatch(saveFolderRoot(collection.uid, item.uid));
} else {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
}
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionSettings(collection.uid));
}
}, userKeyBindings);
// SEND REQUEST
bindHotkey('sendRequest', () => {
const state = getState();
const tabs = state.tabs.tabs;
const collections = state.collections.collections;
const activeTabUid = state.tabs.activeTabUid;
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (!activeTab) return;
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (!collection) return;
const item = findItemInCollection(collection, activeTab.uid);
if (!item) return;
if (item.type === 'grpc-request') {
const request = item.draft ? item.draft.request : item.request;
if (!request.url) return toast.error('Please enter a valid gRPC server URL');
if (!request.method) return toast.error('Please select a gRPC method');
}
dispatch(sendRequest(item, collection.uid)).catch(() =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, { duration: 5000 })
);
}, userKeyBindings);
// EDIT ENV
bindHotkey('editEnvironment', () => {
const state = getState();
const tabs = state.tabs.tabs;
const collections = state.collections.collections;
const activeTabUid = state.tabs.activeTabUid;
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (!activeTab) return;
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (!collection) return;
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}, userKeyBindings);
// NEW REQUEST -> trigger via event so the provider can open the modal
bindHotkey('newRequest', () => {
window.dispatchEvent(new CustomEvent('new-request-open'));
}, userKeyBindings);
// GLOBAL SEARCH -> trigger via event so the provider can open the modal
bindHotkey('globalSearch', () => {
window.dispatchEvent(new CustomEvent('global-search-open'));
}, userKeyBindings);
// CLOSE TAB
bindHotkey('closeTab', () => {
window.dispatchEvent(new CustomEvent('close-active-tab'));
}, userKeyBindings);
// SWITCH PREV TAB
bindHotkey('switchToPreviousTab', () => {
dispatch(switchTab({ direction: 'pageup' }));
}, userKeyBindings);
// SWITCH NEXT TAB
bindHotkey('switchToNextTab', () => {
dispatch(switchTab({ direction: 'pagedown' }));
}, userKeyBindings);
// CLOSE ALL TABS
bindHotkey('closeAllTabs', () => {
window.dispatchEvent(new CustomEvent('close-active-tab'));
}, userKeyBindings);
// COLLAPSE SIDEBAR
bindHotkey('collapseSidebar', () => {
dispatch(toggleSidebarCollapse());
}, userKeyBindings);
// MOVE TAB LEFT
bindHotkey('moveTabLeft', () => {
dispatch(reorderTabs({ direction: -1 }));
}, userKeyBindings);
// MOVE TAB RIGHT
bindHotkey('moveTabRight', () => {
dispatch(reorderTabs({ direction: 1 }));
}, userKeyBindings);
// CHANGE LAYOUT -> toggle response pane orientation
bindHotkey('changeLayout', () => {
const state = getState();
const preferences = state.app.preferences;
const currentOrientation = preferences?.layout?.responsePaneOrientation || 'horizontal';
const newOrientation = currentOrientation === 'horizontal' ? 'vertical' : 'horizontal';
const updatedPreferences = {
...preferences,
layout: {
...preferences.layout,
responsePaneOrientation: newOrientation
}
};
dispatch(savePreferences(updatedPreferences));
}, userKeyBindings);
// CLOSE BRUNO -> send IPC to close the window
bindHotkey('closeBruno', () => {
window.ipcRenderer?.send('renderer:window-close');
}, userKeyBindings);
// OPEN PREFERENCES -> open preferences tab
bindHotkey('openPreferences', () => {
const state = getState();
const tabs = state.tabs.tabs;
const activeTabUid = state.tabs.activeTabUid;
const activeTab = tabs.find((t) => t.uid === activeTabUid);
dispatch(
addTab({
type: 'preferences',
uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
collectionUid: activeTab?.collectionUid
})
);
}, userKeyBindings);
// IMPORT COLLECTION -> trigger event to open import modal
bindHotkey('importCollection', () => {
window.dispatchEvent(new CustomEvent('import-collection-open'));
}, userKeyBindings);
// SIDEBAR SEARCH -> trigger event to focus sidebar search
bindHotkey('sidebarSearch', () => {
window.dispatchEvent(new CustomEvent('sidebar-search-open'));
}, userKeyBindings);
// ZOOM IN
bindHotkey('zoomIn', () => {
window.ipcRenderer?.invoke('renderer:zoom-in');
}, userKeyBindings);
// ZOOM OUT
bindHotkey('zoomOut', () => {
window.ipcRenderer?.invoke('renderer:zoom-out');
}, userKeyBindings);
// RESET ZOOM
bindHotkey('resetZoom', () => {
window.ipcRenderer?.invoke('renderer:reset-zoom');
}, userKeyBindings);
// CLONE ITEM -> trigger event so the sidebar can handle opening the clone modal
bindHotkey('cloneItem', () => {
window.dispatchEvent(new CustomEvent('clone-item-open'));
}, userKeyBindings);
// COPY ITEM -> copy currently selected item to clipboard
bindHotkey('copyItem', () => {
window.dispatchEvent(new CustomEvent('copy-item-open'));
}, userKeyBindings);
// PASTE ITEM -> paste from clipboard to current location
bindHotkey('pasteItem', () => {
window.dispatchEvent(new CustomEvent('paste-item-open'));
}, userKeyBindings);
// RENAME ITEM -> trigger event so the sidebar can handle opening the rename modal
bindHotkey('renameItem', () => {
window.dispatchEvent(new CustomEvent('rename-item-open'));
}, userKeyBindings);
}
// -----------------------
// Provider (manages hotkey lifecycle + modal state)
// -----------------------
export const HotkeysProvider = (props) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const collections = useSelector((state) => state.collections.collections);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const userKeyBindings = useSelector((state) => state.app.preferences?.keyBindings);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
const [showImportCollectionModal, setShowImportCollectionModal] = useState(false);
// Keep a ref to the previous userKeyBindings so we can unbind old combos
const prevKeyBindingsRef = useRef(undefined);
const getCurrentCollection = () => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
return collection;
}
if (!activeTab) return undefined;
return findCollectionByUid(collections, activeTab.collectionUid);
};
// save hotkey
const currentCollection = getCurrentCollection();
// Bind/rebind hotkeys whenever user preferences change
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('save')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
if (activeTab.type === 'environment-settings' || activeTab.type === 'global-environment-settings') {
window.dispatchEvent(new CustomEvent('environment-save'));
return false;
}
// Store previous bindings before updating
const prevBindings = prevKeyBindingsRef.current;
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item && item.uid) {
if (activeTab.type === 'folder-settings') {
dispatch(saveFolderRoot(collection.uid, item.uid));
} else {
dispatch(saveRequest(activeTab.uid, activeTab.collectionUid));
}
} else if (activeTab.type === 'collection-settings') {
dispatch(saveCollectionSettings(collection.uid));
}
}
}
// Unbind previous bindings (if any)
if (prevBindings !== undefined) {
unbindAllHotkeys(prevBindings);
}
return false; // this stops the event bubbling
});
// Bind with current preferences
bindAllHotkeys(userKeyBindings);
prevKeyBindingsRef.current = userKeyBindings;
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('save')]);
// Cleanup on unmount
unbindAllHotkeys(userKeyBindings);
};
}, [activeTabUid, tabs, saveRequest, collections, dispatch]);
}, [userKeyBindings]);
// send request (ctrl/cmd + enter)
// Listen for hotkey-triggered events for modals
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('sendRequest')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
const openNewRequest = () => setShowNewRequestModal(true);
const openGlobalSearch = () => setShowGlobalSearchModal(true);
const openImportCollection = () => setShowImportCollectionModal(true);
if (collection) {
const item = findItemInCollection(collection, activeTab.uid);
if (item) {
if (item.type === 'grpc-request') {
const request = item.draft ? item.draft.request : item.request;
if (!request.url) {
toast.error('Please enter a valid gRPC server URL');
return;
}
if (!request.method) {
toast.error('Please select a gRPC method');
return;
}
}
dispatch(sendRequest(item, collection.uid)).catch((err) =>
toast.custom((t) => <NetworkError onClose={() => toast.dismiss(t.id)} />, {
duration: 5000
})
);
}
}
}
return false; // this stops the event bubbling
});
window.addEventListener('new-request-open', openNewRequest);
window.addEventListener('global-search-open', openGlobalSearch);
window.addEventListener('import-collection-open', openImportCollection);
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('sendRequest')]);
};
}, [activeTabUid, tabs, saveRequest, collections]);
// edit environments (ctrl/cmd + e)
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('editEnvironment')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
dispatch(
addTab({
uid: `${collection.uid}-environment-settings`,
collectionUid: collection.uid,
type: 'environment-settings'
})
);
}
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('editEnvironment')]);
};
}, [activeTabUid, tabs, collections, dispatch]);
// new request (ctrl/cmd + b)
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('newRequest')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
setShowNewRequestModal(true);
}
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('newRequest')]);
};
}, [activeTabUid, tabs, collections, setShowNewRequestModal]);
// global search (ctrl/cmd + k)
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('globalSearch')], (e) => {
setShowGlobalSearchModal(true);
return false; // stop bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('globalSearch')]);
window.removeEventListener('new-request-open', openNewRequest);
window.removeEventListener('global-search-open', openGlobalSearch);
window.removeEventListener('import-collection-open', openImportCollection);
};
}, []);
// close tab hotkey
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
if (activeTabUid) {
dispatch(
closeTabs({
tabUids: [activeTabUid]
})
);
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
};
}, [activeTabUid]);
// Switch to the previous tab
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToPreviousTab')], (e) => {
dispatch(
switchTab({
direction: 'pageup'
})
);
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToPreviousTab')]);
};
}, [dispatch]);
// Switch to the next tab
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('switchToNextTab')], (e) => {
dispatch(
switchTab({
direction: 'pagedown'
})
);
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('switchToNextTab')]);
};
}, [dispatch]);
// Close all tabs
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeAllTabs')], (e) => {
const activeTab = find(tabs, (t) => t.uid === activeTabUid);
if (activeTab) {
const collection = findCollectionByUid(collections, activeTab.collectionUid);
if (collection) {
const tabUids = tabs.filter((tab) => tab.collectionUid === collection.uid).map((tab) => tab.uid);
dispatch(
closeTabs({
tabUids: tabUids
})
);
}
}
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeAllTabs')]);
};
}, [activeTabUid, tabs, collections, dispatch]);
// Collapse sidebar (ctrl/cmd + \)
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('collapseSidebar')], (e) => {
dispatch(toggleSidebarCollapse());
return false;
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('collapseSidebar')]);
};
}, [dispatch]);
// Move tab left
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabLeft')], (e) => {
dispatch(reorderTabs({ direction: -1 }));
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabLeft')]);
};
}, [dispatch]);
// Move tab right
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('moveTabRight')], (e) => {
dispatch(reorderTabs({ direction: 1 }));
return false; // this stops the event bubbling
});
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('moveTabRight')]);
};
}, [dispatch]);
const currentCollection = getCurrentCollection();
return (
<HotkeysContext.Provider {...props} value="hotkey">
{showNewRequestModal && (
@@ -293,13 +369,16 @@ export const HotkeysProvider = (props) => {
{showGlobalSearchModal && (
<GlobalSearchModal isOpen={showGlobalSearchModal} onClose={() => setShowGlobalSearchModal(false)} />
)}
{showImportCollectionModal && (
<ImportCollection onClose={() => setShowImportCollectionModal(false)} />
)}
<div>{props.children}</div>
</HotkeysContext.Provider>
);
};
export const useHotkeys = () => {
const context = React.useContext(HotkeysContext);
const context = useContext(HotkeysContext);
if (!context) {
throw new Error(`useHotkeys must be used within a HotkeysProvider`);

View File

@@ -1,41 +1,76 @@
const KeyMapping = {
save: { mac: 'command+s', windows: 'ctrl+s', name: 'Save' },
sendRequest: { mac: 'command+enter', windows: 'ctrl+enter', name: 'Send Request' },
editEnvironment: { mac: 'command+e', windows: 'ctrl+e', name: 'Edit Environment' },
newRequest: { mac: 'command+b', windows: 'ctrl+b', name: 'New Request' },
globalSearch: { mac: 'command+k', windows: 'ctrl+k', name: 'Global Search' },
closeTab: { mac: 'command+w', windows: 'ctrl+w', name: 'Close Tab' },
openPreferences: { mac: 'command+,', windows: 'ctrl+,', name: 'Open Preferences' },
export const DEFAULT_KEY_BINDINGS = {
save: { mac: 'command+bind+s', windows: 'ctrl+bind+s', name: 'Save' },
sendRequest: { mac: 'command+bind+enter', windows: 'ctrl+bind+enter', name: 'Send Request' },
editEnvironment: { mac: 'command+bind+e', windows: 'ctrl+bind+e', name: 'Edit Environment' },
newRequest: { mac: 'command+bind+n', windows: 'ctrl+bind+n', name: 'New Request' },
importCollection: { mac: 'command+bind+o', windows: 'ctrl+bind+o', name: 'Import Collection' },
globalSearch: { mac: 'command+bind+k', windows: 'ctrl+bind+k', name: 'Global Search' },
sidebarSearch: { mac: 'command+bind+f', windows: 'ctrl+bind+f', name: 'Search Sidebar' },
closeTab: { mac: 'command+bind+w', windows: 'ctrl+bind+w', name: 'Close Tab' },
openPreferences: { mac: 'command+bind+,', windows: 'ctrl+bind+,', name: 'Open Preferences' },
changeLayout: { mac: 'command+bind+j', windows: 'ctrl+bind+j', name: 'Change Orientation' },
closeBruno: {
mac: 'command+Q',
windows: 'ctrl+shift+q',
mac: 'command+bind+q',
windows: 'ctrl+bind+shift+bind+q',
name: 'Close Bruno'
},
switchToPreviousTab: {
mac: 'command+pageup',
windows: 'ctrl+pageup',
mac: 'command+bind+2',
windows: 'ctrl+bind+2',
name: 'Switch to Previous Tab'
},
switchToNextTab: {
mac: 'command+pagedown',
windows: 'ctrl+pagedown',
mac: 'command+bind+1',
windows: 'ctrl+bind+1',
name: 'Switch to Next Tab'
},
moveTabLeft: {
mac: 'command+shift+pageup',
windows: 'ctrl+shift+pageup',
mac: 'command+bind+[',
windows: 'ctrl+bind+[',
name: 'Move Tab Left'
},
moveTabRight: {
mac: 'command+shift+pagedown',
windows: 'ctrl+shift+pagedown',
mac: 'command+bind+]',
windows: 'ctrl+bind+]',
name: 'Move Tab Right'
},
closeAllTabs: { mac: 'command+shift+w', windows: 'ctrl+shift+w', name: 'Close All Tabs' },
collapseSidebar: { mac: 'command+\\', windows: 'ctrl+\\', name: 'Collapse Sidebar' },
zoomIn: { mac: 'command+=', windows: 'ctrl+=', name: 'Zoom In' },
zoomOut: { mac: 'command+-', windows: 'ctrl+-', name: 'Zoom Out' },
resetZoom: { mac: 'command+0', windows: 'ctrl+0', name: 'Reset Zoom' }
closeAllTabs: { mac: 'command+bind+shift+bind+w', windows: 'ctrl+bind+shift+bind+w', name: 'Close All Tabs' },
collapseSidebar: { mac: 'command+bind+\\', windows: 'ctrl+bind+\\', name: 'Collapse Sidebar' },
zoomIn: { mac: 'command+bind+=', windows: 'ctrl+bind+=', name: 'Zoom In' },
zoomOut: { mac: 'command+bind+-', windows: 'ctrl+bind+-', name: 'Zoom Out' },
resetZoom: { mac: 'command+bind+0', windows: 'ctrl+bind+0', name: 'Reset Zoom' },
cloneItem: { mac: 'command+bind+d', windows: 'ctrl+bind+d', name: 'Clone Item' },
copyItem: { mac: 'command+bind+c', windows: 'ctrl+bind+c', name: 'Copy Item' },
pasteItem: { mac: 'command+bind+v', windows: 'ctrl+bind+v', name: 'Paste Item' },
renameItem: { mac: 'command+bind+r', windows: 'ctrl+bind+r', name: 'Rename Item' }
};
/**
* Converts keybindings from storage format (+bind+) to Mousetrap format (+)
* Storage format uses +bind+ as separator to avoid conflicts with the actual + key
* Mousetrap uses + as the separator
* Also converts arrow key names to Mousetrap format
*
* @param {string} keysStr - Keybinding string in storage format
* @returns {string|null} Keybinding string in Mousetrap format, or null if empty
*/
export const toMousetrapCombo = (keysStr) => {
if (!keysStr) return null;
// Split by +bind+ separator
const parts = keysStr.split('+bind+').filter(Boolean);
// Convert arrow key names from browser format to Mousetrap format
const converted = parts.map((part) => {
const lower = part.toLowerCase();
if (lower === 'arrowup') return 'up';
if (lower === 'arrowdown') return 'down';
if (lower === 'arrowleft') return 'left';
if (lower === 'arrowright') return 'right';
return lower;
});
return converted.join('+');
};
/**
@@ -46,7 +81,7 @@ const KeyMapping = {
*/
export const getKeyBindingsForOS = (os) => {
const keyBindings = {};
for (const [action, { name, ...keys }] of Object.entries(KeyMapping)) {
for (const [action, { name, ...keys }] of Object.entries(DEFAULT_KEY_BINDINGS)) {
if (keys[os]) {
keyBindings[action] = {
keys: keys[os],
@@ -58,18 +93,57 @@ export const getKeyBindingsForOS = (os) => {
};
/**
* Retrieves the key bindings for a specific action across all operating systems.
* Merges default key bindings with user preferences.
*
* @param {Object} userKeyBindings - User's custom key bindings from preferences (preferences.keyBindings)
* @returns {Object} Merged key bindings object
*/
export const getMergedKeyBindings = (userKeyBindings) => {
const merged = {};
// Start with defaults
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
merged[action] = { ...binding };
}
// Override with user preferences
if (userKeyBindings && typeof userKeyBindings === 'object') {
for (const [action, binding] of Object.entries(userKeyBindings)) {
if (merged[action]) {
merged[action] = { ...merged[action], ...binding };
}
}
}
return merged;
};
/**
* Retrieves the Mousetrap-compatible key combos for a specific action across all operating systems.
* Reads from merged defaults + user preferences.
*
* @param {string} action - The action for which to retrieve key bindings.
* @returns {Object|null} An object containing the key bindings for macOS, Windows, or null if the action is not found.
* @param {Object} [userKeyBindings] - User's custom key bindings from preferences
* @returns {string[]|null} Array of Mousetrap-compatible combo strings, or null if the action is not found.
*/
export const getKeyBindingsForActionAllOS = (action) => {
const actionBindings = KeyMapping[action];
export const getKeyBindingsForActionAllOS = (action, userKeyBindings) => {
const merged = getMergedKeyBindings(userKeyBindings);
const actionBindings = merged[action];
if (!actionBindings) {
console.warn(`Action "${action}" not found in KeyMapping.`);
return null;
}
return [actionBindings.mac, actionBindings.windows];
const combos = [];
if (actionBindings.mac) {
const combo = toMousetrapCombo(actionBindings.mac);
if (combo) combos.push(combo);
}
if (actionBindings.windows) {
const combo = toMousetrapCombo(actionBindings.windows);
if (combo) combos.push(combo);
}
return combos.length > 0 ? combos : null;
};

View File

@@ -4,7 +4,7 @@ import filter from 'lodash/filter';
import { createListenerMiddleware } from '@reduxjs/toolkit';
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
import { collectionAddFileEvent, collectionChangeFileEvent, collectionBatchAddItems } from 'providers/ReduxStore/slices/collections';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
import { taskTypes } from './utils';
@@ -51,6 +51,57 @@ taskMiddleware.startListening({
}
});
/*
* When files are added via batch processing (e.g., during collection mount or when new files are created),
* we need to check if any of the added files match pending OPEN_REQUEST tasks.
* This handles the case where file additions go through the batch reducer instead of individual events.
*/
taskMiddleware.startListening({
actionCreator: collectionBatchAddItems,
effect: (action, listenerApi) => {
const state = listenerApi.getState();
const items = action.payload?.items || [];
// Extract all addFile events from the batch
const addFileItems = items.filter((item) => item.eventType === 'addFile');
if (addFileItems.length === 0) return;
const openRequestTasks = filter(state.app.taskQueue, { type: taskTypes.OPEN_REQUEST });
if (openRequestTasks.length === 0) return;
each(addFileItems, ({ payload: file }) => {
const collectionUid = file?.meta?.collectionUid;
if (!collectionUid) return;
each(openRequestTasks, (task) => {
if (collectionUid === task.collectionUid && file?.meta?.pathname === task.itemPathname) {
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
const item = findItemInCollectionByPathname(collection, task.itemPathname);
const isTransient = item?.isTransient ?? false;
if (item) {
listenerApi.dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item),
preview: !isTransient
})
);
}
}
listenerApi.dispatch(
removeTaskFromQueue({
taskUid: task.uid
})
);
}
});
});
}
});
/*
* When an example is created or cloned, a task to open the example is added to the queue.
* We wait for the File IO to complete, after which the "collectionChangeFileEvent" gets dispatched.

View File

@@ -34,7 +34,11 @@ const initialState = {
codeFont: 'default'
},
general: {
defaultCollectionLocation: ''
defaultLocation: ''
},
onboarding: {
hasLaunchedBefore: false,
hasSeenWelcomeModal: true
},
autoSave: {
enabled: false,
@@ -53,7 +57,11 @@ const initialState = {
clipboard: {
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
},
systemProxyVariables: {}
systemProxyVariables: {},
envVarSearch: {
collection: { query: '', expanded: false },
global: { query: '', expanded: false }
}
};
export const appSlice = createSlice({
@@ -141,6 +149,14 @@ export const appSlice = createSlice({
setClipboard: (state, action) => {
// Update clipboard UI state
state.clipboard.hasCopiedItems = action.payload.hasCopiedItems;
},
setEnvVarSearchQuery: (state, { payload: { context, query } }) => {
if (!state.envVarSearch[context]) return;
state.envVarSearch[context].query = query;
},
setEnvVarSearchExpanded: (state, { payload: { context, expanded } }) => {
if (!state.envVarSearch[context]) return;
state.envVarSearch[context].expanded = expanded;
}
},
extraReducers: (builder) => {
@@ -182,7 +198,9 @@ export const {
updateGitOperationProgress,
removeGitOperationProgress,
setGitVersion,
setClipboard
setClipboard,
setEnvVarSearchQuery,
setEnvVarSearchExpanded
} = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => {

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