Compare commits

..

188 Commits

Author SHA1 Message Date
shubh-bruno
77916019cd fix: status & statusText swap (#7589)
* fix: status & statusText swap

* chore: typo

* test: tests for swapping status and statusText

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-02 21:01:10 +05:30
Pooja
02aa669578 fix: convert non-string variable values to strings during postman import (#7476) 2026-04-02 21:00:39 +05:30
sanish chirayath
d8809e09e7 Fix: ensure string authvalues, string header processing (#7646)
* feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion

- Introduced `ensureString` function to convert numeric and non-string values to strings, defaulting null/undefined to an empty string.
- Updated request handling in `importPostmanV2CollectionItem` to utilize `ensureString` for headers, parameters, and body fields.
- Added tests to verify correct conversion of numeric values to strings in headers, query parameters, and body fields.

* test: add test for numeric value conversion in Postman to Bruno transformation

- Implemented a new test case to verify that numeric values in example request and response fields are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks various components including request headers, query parameters, path parameters, and body fields to ensure proper string conversion.

* test: add multipart form value test for numeric conversion in Postman to Bruno transformation

- Added a new test case to verify that numeric values in multipart form data are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks the conversion of numeric values in the request body to ensure proper handling in the transformation.

* feat: enhance header parsing in Postman to Bruno conversion

- Added `parseStringHeader` and `normalizeHeaders` functions to handle various header formats, including string headers and concatenated strings.
- Updated the request and response handling in `importPostmanV2CollectionItem` to utilize the new header normalization logic.
- Introduced tests to verify correct parsing of string headers, including cases with no values and concatenated headers.

* refactor: enhance ensureString function for flexible fallback values

- Updated the `ensureString` function to accept a fallback parameter, allowing for customizable default values instead of a fixed empty string for null/undefined inputs.
- Modified the usage of `ensureString` in the `processAuth` function to utilize the new fallback feature for various authentication fields, improving the handling of optional values.

* refactor: update ensureString function to handle empty values

- Modified the `ensureString` function to return the fallback for null, undefined, or empty string values, enhancing its flexibility in handling various input scenarios.

* chore: update ESLint configuration and enhance Postman to Bruno conversion tests

- Added 'no-case-declarations' rule to ESLint configuration to enforce stricter coding standards.
- Modified the `processAuth` function to ensure proper block scoping for OAuth2 case handling.
- Improved header parsing logic to check for string type in content-type header.
- Added new tests to verify conversion of numeric authentication values to strings in both array-backed and object-backed formats during Postman to Bruno transformation.

* chore: update ESLint configuration to enforce stricter rules

- Added 'no-case-declarations' rule to ESLint configuration to enhance code quality.
- Adjusted existing rules for consistency and clarity in the configuration.

---------

Co-authored-by: Bijin A B <bijin@usebruno.com>
2026-04-02 20:59:32 +05:30
sanish chirayath
04fdd6f8a9 feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion (#7644)
* feat: add helper to ensure string conversion for non-string values in Postman to Bruno conversion

- Introduced `ensureString` function to convert numeric and non-string values to strings, defaulting null/undefined to an empty string.
- Updated request handling in `importPostmanV2CollectionItem` to utilize `ensureString` for headers, parameters, and body fields.
- Added tests to verify correct conversion of numeric values to strings in headers, query parameters, and body fields.

* test: add test for numeric value conversion in Postman to Bruno transformation

- Implemented a new test case to verify that numeric values in example request and response fields are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks various components including request headers, query parameters, path parameters, and body fields to ensure proper string conversion.

* test: add multipart form value test for numeric conversion in Postman to Bruno transformation

- Added a new test case to verify that numeric values in multipart form data are correctly converted to strings during the Postman to Bruno conversion process.
- The test checks the conversion of numeric values in the request body to ensure proper handling in the transformation.
2026-04-02 20:58:24 +05:30
Sid
3097f3aa76 fix: update system proxy fetching to use finally (#7652)
* fix: update system proxy fetching to use finally for improved reliability

* Update packages/bruno-electron/src/index.js

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-02 20:56:34 +05:30
Sid
9c3eabdda2 chore: add a promise based wait group for the shell variables (#7647) 2026-04-02 20:56:34 +05:30
Sid
7c4da8b8bc security: fix all critical vuln dependency reports (#7645)
* chore: remove form-data vuln

* chore: stale aws in lock

* chore: other critical vulns

* chore: correct deps
2026-04-01 23:42:06 +05:30
Chirag Chandrashekhar
1e4c3464d2 fix: app crash on clicking close button (#7637)
* fix: app crash on clicking close button \n Added collection, workspace, and api spec watcher cleanup on app close method

* fix: close file watchers before app exit to prevent crash on macOS

Close all chokidar file watchers (collection, workspace, apiSpec) before
the Node environment is torn down. The native FSEvents watchers run on
their own threads and their cleanup races with FreeEnvironment, causing
an abort when fse_instance_destroy tries to lock a destroyed mutex.

Watchers are closed in both mainWindow.on('close') and app.on('before-quit')
to cover the native close button path and the app.exit() path.

* fix: move watcher cleanup from close handler to before-quit only

The close event is cancelable — if the user cancels the unsaved changes
dialog, watchers would remain closed for the rest of the session.
Move closeAllWatchers() to before-quit which only fires on actual quit.

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-01 23:39:53 +05:30
lohit
5695f69430 fix: recreate HTTP/HTTPS agents on redirect to prevent stale agent reuse (#7597) (#7615)
When a request redirected from HTTP to HTTPS (or vice versa), the
original httpAgent/httpsAgent leaked into the redirect config. The
httpsAgent — which carries custom CA certificates and TLS options — was
never created for the redirect URL, causing UNABLE_TO_VERIFY_LEAF_SIGNATURE.

Changes:
- setupProxyAgents (electron) now deletes stale agents at the top of
  every call so they are always recreated for the current URL
- setupProxyAgents extracted to bruno-cli/proxy-util.js (mirrors the
  electron version) and called on every redirect in the CLI path
- Removed the else-branch in bruno-requests/http-https-agents.ts that
  only created one agent based on initial protocol
- Added HTTP→HTTPS redirect test server and request to the
  custom-ca-certs SSL test suite
2026-04-01 23:39:29 +05:30
Sid
d0bbac6b66 fix(security): santize HTML before being rendered in documentation blocks (#7598)
* fix: purify markdown before rendering

* chore: resolve stale html
2026-04-01 23:35:53 +05:30
Abhishek S Lal
51e2c045ec fix: re-apply masking in MultiLineEditor and SingleLineEditor after setValue() to preserve CodeMirror marks (#7585) 2026-04-01 23:35:45 +05:30
Pooja
b585c3e943 fix: preserve query params without values by not appending = sign (#7567)
* fix: preserve query params without values by not appending = sign

* fix: parseCurlCommand test
2026-04-01 23:35:38 +05:30
Chirag Chandrashekhar
8150a21395 fix: preserve user-defined boundary in multipart/mixed Content-Type header (#7531)
* fix: preserve user-defined boundary in multipart/mixed Content-Type header

When users specify a boundary parameter in their Content-Type header for
multipart/mixed requests with TEXT body mode, Bruno now preserves the
user-defined boundary instead of generating a new one.

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

* updated the test to use local server and changed the request method to GET

* fix: handle quoted boundary values in Content-Type header extraction

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-04-01 23:35:00 +05:30
Pragadesh-45
0470e8d1a7 feat: include pinned data in filtering for more accurate results in env variables search (#7513) 2026-03-18 18:53:46 +05:30
naman-bruno
031b373bac fix: clear draft on save and update dependencies in useEffect (#7512) 2026-03-18 18:53:41 +05:30
naman-bruno
586fd6b7f6 refactor: optimize formik value handling and improve save conditions (#7507)
* refactor: optimize formik value handling and improve save conditions

* fix
2026-03-18 18:53:35 +05:30
naman-bruno
51765da0b1 refactor: optimize debounced save functionality (#7495) 2026-03-18 18:51:18 +05:30
sanish chirayath
5b02aad92a feat(bruno-js): add hasCookie function to cookie jar shim for improved cookie management (#7501) 2026-03-18 18:49:21 +05:30
Abhishek S Lal
606d03180f fix(openapi-sync): simplify IPC calls, fix state priorities, and improve stored spec missing UX (#7489)
* refactor(OpenAPISyncTab): remove unused props and streamline IPC calls

- Eliminated unnecessary sourceUrl prop from various components and hooks in the OpenAPISyncTab.
- Improved pretty-printing logic in OpenAPISpecTab to handle non-JSON content gracefully.
- Updated IPC calls to remove redundant parameters, enhancing code clarity and maintainability.

* feat(OpenAPISyncTab): enhance user interaction and visual feedback

- Added onTabSelect prop to OpenAPISyncTab for improved tab navigation.
- Updated color properties in StyledWrapper for better consistency with theme.
- Replaced IconClock with IconAlertTriangle in CollectionStatusSection for clearer status indication.
- Enhanced messaging in OverviewSection and SpecStatusSection to provide clearer user guidance.
- Introduced handleRestoreSpec function in useSyncFlow for better spec restoration handling.

* fix(OpenAPISyncTab): update button labels for clarity in OverviewSection

- Changed button label from 'restore' to 'spec-details' for better context.
- Updated the button text from 'View Details' to 'Go to Spec Updates' to enhance user understanding of navigation options.

* refactor(OpenAPISyncTab): remove unused props and streamline component logic

- Eliminated unnecessary props from OpenAPISyncTab, CollectionStatusSection, and SpecStatusSection for cleaner code.
- Removed commented-out code in OverviewSection and SpecStatusSection to enhance readability.
- Introduced posixifyPath utility function in filesystem.js to standardize path formatting.

* fix(OpenAPISyncTab): update openapi config handling to support array format

- Modified the logic in loadBrunoConfig to handle openapi as an array, ensuring consistent resolution of source URLs for all entries. This change improves the configuration handling for OpenAPI specifications.

* fix(OpenAPISyncTab): improve openapi config handling and merge logic

- Updated loadBrunoConfig to ensure openapi is treated as an array, enhancing source URL resolution.
- Modified mergeWithUserValues to handle cases where specItems may be undefined, improving robustness in merging user values with specifications.
2026-03-18 18:48:38 +05:30
Chirag Chandrashekhar
a86551ad27 fix(RequestTabPanel): update loading message for better user feedback (#7492)
Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-18 18:48:10 +05:30
Abhishek S Lal
60f8611dd7 refactor(OpenAPISyncTab): streamline component logic and enhance user feedback (#7483)
- Removed unused props and improved error handling in OpenAPISyncTab components.
- Updated messaging in CollectionStatusSection and OverviewSection for clarity.
- Enhanced the SpecDiffModal to provide better visual feedback on changes.
- Refactored sync flow logic to ensure accurate endpoint categorization and improved performance.
- Added new utility functions for better handling of spec changes and endpoint comparisons.
2026-03-18 18:46:03 +05:30
Bohdan
09be7131cc add missing color to scrollbar-color property (#7481) 2026-03-18 18:45:10 +05:30
Thomas
d45a975335 feat: remove .bru reference in error message (#7479)
Co-authored-by: Thomas Vackier <thomas.vackier@inthepocket.com>
2026-03-18 18:44:51 +05:30
Abhishek S Lal
6f82eae80f feat: improve OpenAPI Sync tab UX and fix sync flow bugs (#7467)
* fix: specify OpenAPI 3.x in error messages for file uploads and URL validation

Updated error messages in ConnectSpecForm and ConnectionSettingsModal to clarify that only OpenAPI 3.x specifications are valid. Enhanced useOpenAPISync hook to reflect the same specificity in error handling for invalid URLs.

* feat(OpenAPISpecTab): add pretty-printing for JSON content in API spec viewer

Implemented a new function to pretty-print JSON content for improved readability in the OpenAPISpecTab component. This enhancement ensures that JSON specifications are displayed in a more user-friendly format while leaving YAML content unchanged.

* feat(OpenAPISyncHeader): resolve and display absolute file paths for local sources

Added functionality to resolve relative file paths to absolute paths for better user experience in the OpenAPISyncHeader component. Implemented state management and side effects to handle path resolution based on the source URL, enhancing the display of local file paths.

* feat(OpenAPISyncTab): enhance collection status display and add endpoint counting utility

Refactored the CollectionStatusSection to streamline the display of collection drift status, integrating loading states and improved messaging for initial sync scenarios. Introduced a new utility function to count HTTP endpoints in OpenAPI specifications, enhancing the overall functionality of the OpenAPISyncTab. Additionally, updated the OpenAPISyncHeader and OverviewSection to utilize stored specification metadata for better user experience.

* refactor: improve OpenAPI Sync endpoint handling

- Enhanced the logic for adding new requests by ensuring existing files are verified before removal to prevent accidental deletions.
- Streamlined the process of adding new endpoints, including checks for existing files and merging requests to maintain user customizations.
- Added comments for clarity on the purpose of changes, particularly regarding filename collision prevention and file content verification.

* style(OpenAPISyncTab): update styles for improved visual feedback

- Changed background color for the 'type-spec-modified' class to a warning color for better distinction.
- Updated text color and background for the SyncReviewPage to enhance readability and visual hierarchy.
- Adjusted default expanded states for endpoint sections to improve user experience during sync reviews.

* chore: update .gitignore and enhance OpenAPISyncTab components

- Added new entries to .gitignore for agent-related files and skills-lock.json.
- Modified StyledWrapper to improve overflow handling and added sticky headers for better visibility.
- Introduced loading state in SpecDiffModal with a spinner for improved user feedback during rendering.

* feat(OpenAPISpecTab): integrate fast-json-format for improved JSON rendering

- Replaced the JSON parsing and stringifying logic with fast-json-format for better performance in pretty-printing API specifications.
- Updated StyledWrapper in OpenAPISyncTab to change background and text colors for enhanced visual consistency.
- Modified DisconnectSyncModal button to include a secondary color for improved visibility during user interactions.

* fix(OpenAPISyncTab): correct punctuation in status messages and subtitles

- Removed unnecessary trailing periods in messages related to syncing and restoring specifications across CollectionStatusSection and OverviewSection components.
- Updated SyncReviewPage to correct grammatical error in the description of spec updates.

* fix(OpenAPISyncTab): update URL validation to use isHttpUrl

- Replaced isValidUrl with isHttpUrl in ConnectSpecForm and ConnectionSettingsModal components to ensure only valid HTTP URLs are accepted.
- Updated the logic for enabling the save button based on the new URL validation method.

* fix(OpenAPISyncTab): normalize source URL before validation

- Trimmed the source URL in ConnectionSettingsModal to ensure consistent validation with isHttpUrl.
- Updated state initialization for URL and filePath to use the normalized source URL, improving handling of user input.
2026-03-18 18:44:28 +05:30
Abhishek S Lal
ab2326deb3 fix(collection-watcher): prevent crash when deleting collections (#7470)
* fix(collection-watcher): guard against events firing after collection deletion

When deleting an OpenAPI-synced collection, saveBrunoConfig() writes to
bruno.json which creates buffered chokidar events (80ms stabilityThreshold).
If the collection directory is removed before those events fire,
getCollectionFormat() throws "No collection configuration found" for each
.bru file in the collection.

Add fs.existsSync(collectionPath) guards in the change, unlink, and
unlinkDir handlers to bail out early when the collection root no longer exists.

* fix(workspaces): ensure collection watcher stops before deletion

Added logic to remove the collection from the watcher when deleting files, preventing chokidar from firing events on a directory that is being removed. This change enhances stability during collection deletions by ensuring the watcher is properly managed.

* refactor(workspaces): remove redundant collection watcher logic during deletion

Eliminated the logic for stopping the collection watcher before deletion, streamlining the action for removing collections from workspaces. This change simplifies the code and maintains functionality without unnecessary complexity.

* test(collection): add integration test for collection deletion functionality

Introduced a new test suite to verify the deletion of collections from the workspace overview. The test ensures that collections are properly removed from both the UI and the file system, confirming the absence of any uncaught errors during the deletion process. This addition enhances the test coverage for collection management features.

* feat(collection): implement deleteCollectionFromOverview utility function

Added a new utility function to delete a collection directly from the workspace overview page. This function encapsulates the steps required to navigate the UI, confirm deletion, and ensure the collection is removed from both the interface and the file system. Updated the corresponding test to utilize this new function, enhancing code reusability and test clarity.

* fix(collection-watcher): add guards for collection path existence and error handling

Enhanced the unlink and unlinkDir functions to check for the existence of the collection path before proceeding. Added error handling for the getCollectionFormat function to prevent crashes when the collection format cannot be retrieved. These changes improve stability and robustness during collection deletion operations.
2026-03-18 18:44:04 +05:30
naman-bruno
5a4d337ed3 feat: integrate deferred loading for saving state in DotEnvFileEditor (#7463) 2026-03-18 18:43:48 +05:30
naman-bruno
cc197e0c30 feat: implement temporary workspace creation and confirmation flow (#7462)
* feat: implement temporary workspace creation and confirmation flow

* fixes
2026-03-18 18:43:30 +05:30
Pragadesh-45
9fa6acca4e refactor: simplify environment list actions and improve styling (#7459) 2026-03-18 18:43:05 +05:30
naman-bruno
da892243d2 refactor: update path imports to use utils/common/path (#7440) 2026-03-18 18:42:43 +05:30
Pooja
994b60678e fix: multipart header check (#7444)
* fix: multipart header check

* fix
2026-03-18 18:42:29 +05:30
lohit
e001b6ba51 fix: cookie wrapper callback mode returns never-resolving Promise (#7442)
* fix: cookie wrapper callback mode returns never-resolving Promise

tough-cookie's createPromiseCallback() intentionally never resolves the
returned Promise when a callback is provided — only the callback fires.
The cookie jar wrapper was propagating this never-resolving Promise via
`return cookieJar.getCookies(url, cb)` in callback-mode paths. When user
scripts did `await jar.getCookie(url, name, callback)` in the Node VM
(developer sandbox), the await hung forever, blocking the CLI runner.

Fix: drop the return value in all callback-mode paths so the wrapper
returns void (undefined). `await undefined` resolves immediately.

Affected methods: getCookie, getCookies, hasCookie, clear, deleteCookies.

* fix: validation-error callback paths also return void instead of callback result

The validation guards (e.g. missing URL) did `return callback(error)` which
leaks whatever the user's callback returns. Apply the same pattern used for
the main callback paths: call the callback, then return void.

Also makes deleteCookie's `return executeDelete(callback)` consistent
(executeDelete already returns void, but the explicit pattern is clearer).
2026-03-18 18:42:13 +05:30
Sid
59453536a6 revert: feat(phase-1): allow user to customize keybindings#7163 (#7457)
* Revert "feat(phase-1): allow user to customize keybindings (#7163)"

This reverts commit 14532b48a6.

* Revert "chore: UI Polish for Zoom and Keybindings panel (#7376)"

This reverts commit 5151d29aac.
2026-03-18 18:41:43 +05:30
Chirag Chandrashekhar
7fc4ff274d fix: normalize paths when comparing workspace and redux collection paths on Windows (#7436)
Without path normalization, collections appear stuck in "mounting" state on Windows
because workspace YAML uses forward slashes while Redux uses backslashes.

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-03-18 18:40:36 +05:30
Abhishek S Lal
663ece708e Feat/openapi sync beta tag (#7461)
* feat: introduce OpenAPI Sync beta feedback feature

- Added a feedback section in the OpenAPISyncTab and ConnectSpecForm to encourage user input during the beta phase.
- Styled the feedback message and button for better visibility.
- Updated the beta features list to include OpenAPI Sync and adjusted related components to reflect its beta status with appropriate badges.
- Enhanced the StatusBadge component to support a new 'xs' size for better integration in various UI elements.

* feat: integrate OpenAPI Sync beta feature toggle

- Updated the ImportCollectionLocation component to conditionally enable the "Check for Spec Updates" option based on the OpenAPI Sync beta feature status.
- Modified default preferences to disable OpenAPI Sync by default, ensuring users are not prompted for updates unless explicitly enabled.

* feat: enhance beta features integration in Preferences

- Updated the BETA_FEATURES array to use constants from utils/beta-features for better maintainability.
- Improved the handling of beta preferences by merging new preferences with existing ones, ensuring a smoother user experience when toggling features.

* feat: enhance OpenAPI Sync polling with beta feature toggle

- Integrated a beta feature toggle for OpenAPI Sync polling, allowing conditional activation based on user settings.
- Updated the pollingEnabled logic to incorporate the new beta feature status, ensuring better control over sync behavior.
2026-03-18 18:39:37 +05:30
Abhishek S Lal
4d17809562 enhance OpenAPI sync with validation, enum support, and bug fixes (#7408)
* Enhance OpenAPISyncTab functionality with error handling and UI improvements

- Updated ConnectSpecForm to include error handling for invalid OpenAPI specifications when uploading files.
- Added a sync info notice in CollectionStatusSection to inform users about tracked changes.
- Improved styling in StyledWrapper for better visual feedback and layout consistency.
- Adjusted button colors and properties in ConfirmSyncModal and ConnectionSettingsModal for better UX.
- Refactored useOpenAPISync hook to validate URLs before syncing, ensuring only valid OpenAPI specs are processed.
- Enhanced parameter handling in openapi-to-bruno.js to support enum and default values more effectively.

* Refactor OpenAPISyncTab components for improved URL validation and error handling

- Updated ConnectSpecForm to streamline file upload error handling for OpenAPI specifications.
- Enhanced OpenAPISyncHeader to utilize isHttpUrl for better URL validation.
- Refactored useOpenAPISync hook to replace isValidUrl with isHttpUrl for consistency in URL checks.
- Improved file parsing logic in file-reader.js to handle case-insensitive JSON file extensions.
- Added isHttpUrl utility function to validate HTTP/HTTPS URLs effectively.

* Enhance file parsing logic in file-reader.js to improve error handling for JSON and YAML files

- Updated parseFileAsJsonOrYaml function to handle case-insensitive JSON file extensions more robustly.
- Added error handling to ensure the document root is an object and not an array, improving data validation.

* Update StatusBadge component to include new 'xs' size preset and adjust documentation accordingly

- Added 'xs' size preset with specific font size and padding for minimal use cases.
- Updated documentation to reflect the new size options available for the StatusBadge component.
2026-03-10 17:15:45 +05:30
sanish chirayath
f123a2b574 fix: app crash error (Rendered fewer hooks than expected) (#7407)
* fix:  app crash error (Rendered fewer hooks than expected)

* empty commit
2026-03-09 19:20:39 +05:30
shubh-bruno
facfe325b1 fix: allow edit keybinding shortcuts (#7404)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-03-09 17:17:42 +05:30
gopu-bruno
707fd405ff Fix: resolve default location missing path (#7391)
* fix: refocus collection name input on error and resolve missing default location folder

* revert: remove focus refocus logic from InlineCollectionCreator

Reverts the setTimeout focus change that was causing test failures.
Keeps the preferences.js fix for default location validation.

Made-with: Cursor

* fix: update default-collection-location test to use project path

Use {{projectRoot}} template variable for defaultLocation so the path
exists when the app validates it with fs.existsSync.

Made-with: Cursor

* fix: improve save test to use alternate path and verify persistence

Made-with: Cursor

---------

Co-authored-by: naman-bruno <naman@usebruno.com>
2026-03-07 00:23:01 +05:30
naman-bruno
12ebfee9c6 fix: update collection path handling to use path.join for consistency across components (#7394) 2026-03-06 23:06:47 +05:30
Abhishek S Lal
553c45833c refactor: enhance OpenAPISyncTab functionality and clean up unused code (#7392)
- Updated OpenAPISyncTab to utilize Redux state for active tab management, improving state consistency.
- Removed unnecessary loading state checks from OverviewSection and SpecStatusSection for cleaner logic.
- Streamlined prop usage in OverviewSection by eliminating the isLoading prop.
- Cleaned up useOpenAPISync hook by removing unused state clearing logic on unmount.
- Improved file handling in openapi-sync IPC by ensuring new files are created in the appropriate folder based on tags.
2026-03-06 22:12:52 +05:30
lohit
af4c4b24e6 fix: preserve existing process.env values in initializeShellEnv (#7390) 2026-03-06 22:09:19 +05:30
gopu-bruno
1b8cee4706 fix: use Title Case for default "Untitled Collection" and "Untitled Workspace" (#7389) 2026-03-06 18:12:10 +05:30
shubh-bruno
5151d29aac chore: UI Polish for Zoom and Keybindings panel (#7376)
* chore: ui polishing for zoom panel

* fix: replace IconRefresh with IconReload in Zoom and Keybindings components

* chore: resolved ui issues

* test: check test cases

* fix: adjust width properties in StyledWrapper component

* fix: update keybindings UI layout and tooltip removal

* chore: semantics

---------

Co-authored-by: Sid <siddharth@usebruno.com>
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-03-06 13:15:40 +05:30
Abhishek S Lal
1748741d7f refactor: simplify OpenAPISyncTab and related components by removing unused state and styles (#7378)
- Removed the `viewMode` state and associated logic from OpenAPISyncTab and useSyncFlow, streamlining the component's functionality.
- Eliminated the `review-active` class from StyledWrapper, cleaning up the styling.
- Updated Redux state management by removing `viewMode` from the tab UI state structure, reducing complexity.
2026-03-05 21:48:31 +05:30
Abhishek S Lal
b2f8b3bb5b Feat/opeanpi sync updates (#7374)
* fix: update button colors and streamline props in OpenAPISyncTab and CollectionHeader

- Changed button color from 'warning' to 'primary' in OpenAPISyncTab for better visual consistency.
- Simplified prop usage for OpenAPISyncIcon in CollectionHeader by removing unnecessary function wrapper.

* fix: update StatusBadge variant in OpenAPISyncHeader for improved styling

- Changed StatusBadge in OpenAPISyncHeader to use 'outline' variant for better visual distinction of version information.
2026-03-05 19:16:13 +05:30
lohit
f5e437adaf fix: enable SSL session caching and HTTP agent reuse for faster consecutive requests (#6987)
* fix: enable SSL session caching for faster consecutive requests (#6929)

* fix: enable SSL session caching for faster consecutive requests

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

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

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

Fixes #5574

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

* fix: address review feedback for SSL session caching

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: lohit <lohit@usebruno.com>

* feat(bruno-requests): add timeline agent for TLS event logging

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

* feat(bruno-requests): add agent cache for SSL session reuse

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

* test(bruno-requests): add tests for agent cache

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

* feat(bruno-requests): integrate agent cache into http-https-agents

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

* refactor(bruno-electron): use shared agent cache from bruno-requests

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

* feat(bruno-cli): use agent cache for SSL session reuse

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

* feat(bruno-requests): add HTTP agent timeline support

Add createTimelineHttpAgentClass for logging HTTP connection events
including proxy usage, DNS lookups, and connection establishment.

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

* refactor(bruno-requests): extract shared agent caching logic

Add getOrCreateAgentInternal helper to reduce code duplication
between getOrCreateAgent and getOrCreateHttpAgent functions.

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

* feat(bruno-requests): use HTTP agent cache for connection reuse

Export getOrCreateHttpAgent and use it in http-https-agents for
HTTP requests to enable connection pooling.

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

* fix(bruno-cli): improve HTTP agent handling and error logging

- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Add warning log for system proxy configuration errors
- Fix brace style consistency

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

* fix(bruno-electron): improve HTTP agent handling

- Use { keepAlive: true } instead of tlsOptions for HTTP agents
- Fix brace style consistency
- Add missing newline at EOF

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

* fix(bruno-requests): address code review findings for agent caching

- Fix Buffer hashing bug: properly handle Buffer values in hashValue()
- Add CA array support: new hashCaValue() handles string[] | Buffer[]
- Fix timeline race condition: capture timeline reference in closure
  at createConnection start to isolate concurrent requests
- Fix SSL verify message: check socket.authorized for accurate status
- Fix HTTP/HTTPS agent logic: only set httpsAgent for HTTPS requests
- Add tests for concurrent requests timeline isolation

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

* feat(bruno-requests): log when reusing cached agent

- HTTPS agents: "Reusing cached agent (SSL session reuse enabled)"
- HTTP agents: "Reusing cached agent (connection reuse enabled)"

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

* feat(preferences): add cache.httpHttpsAgents.enabled preference

* feat(agent-cache): add disableCache option to getOrCreateAgent

* feat(proxy-util): respect httpHttpsAgents cache preference

* refactor(agent-cache): use named props for getOrCreateAgent and getOrCreateHttpAgent

* feat(ipc): add renderer:clear-http-https-agent-cache handler

* feat(redux): add cache.httpHttpsAgents preferences to initial state

* feat(ui): add Cache tab to Preferences

* feat(cli): add --disable-http-https-agents-cache flag

* refactor(cache): replace window.ipcRenderer calls with redux actions

Add getCacheStats, purgeCache, and clearHttpHttpsAgentCache thunks to
the app slice. Update the Cache preferences component to dispatch these
actions instead of calling window.ipcRenderer directly.

Also move handleSave and handleSaveRef above useFormik to fix declaration
order — onSubmit closes over handleSaveRef, so the ref must be initialized
before useFormik is called.

* fix: tests

* fix(cache): thread disableCache and hostname through all agent-creation paths

- Forward disableHttpHttpsAgentsCache through getHttpHttpsAgents → createAgents
  so OAuth2 token requests and bru.sendRequest honour the CLI flag
- Add hostname to agent cache keys (getAgentCacheKey, getHttpAgentCacheKey)
  for per-host TLS session reuse; extract hostname at every call site in
  run-single-request.js, proxy-util.js, and http-https-agents.ts
- Add extractHostname helper in http-https-agents.ts to safely parse hostnames
- Add test coverage for cert, key, pfx, passphrase, and hostname cache-key
  differentiation in agent-cache.spec.ts

* refactor(cache): rename getOrCreateAgent to getOrCreateHttpsAgent

* refactor: simplify UI labels, optimize agent timeline wrapping, silence proxy errors

* fix: tests

* fix(proxy): fix proxy agent construction and CA cert handling

Three fixes:

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

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

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

Also simplified PatchedHttpsProxyAgent to selectively forward only the
relevant TLS options (cert, key, pfx, passphrase, rejectUnauthorized,
secureContext) to the target TLS upgrade instead of blindly merging all
constructor options.

* fix(tls): load client certs into secureContext to prevent silent drop

Add Cache tab to Preferences UI

* fix(proxy): align proxy auth check to use auth.disabled field consistently

* refactor(cache): rename CLI flag to --cache-ssl-session and disable caching by default

- Rename --disable-http-https-agents-cache to --cache-ssl-session (opt-in)
- Rename disableHttpHttpsAgentsCache to cacheSslSession across CLI and bruno-requests
- Default caching to disabled in both bruno-electron and bruno-cli
- Add cacheSslSession to buildCertsAndProxyConfig for bru.sendRequest
- Update Preferences UI labels to "Cache SSL Session"

* refactor(cache): rename httpHttpsAgents to sslSession across preferences and UI

* refactor(cache): remove unused getCacheStats and purgeCache IPC actions

---------

Co-authored-by: karthik <47263234+kxbnb@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 18:46:20 +05:30
Abhishek S Lal
39f8ce2a2f feat: enhance OpenAPI Sync tab with sync status indicators and improved styling (#7371)
- Added sync status logic to determine if the collection is in sync with the spec, displaying appropriate indicators in the UI.
- Updated OpenAPISyncHeader to include linked collection information and sync status icons.
- Refined styling in StyledWrapper for better layout and visual feedback on sync status.
- Removed unused styles and components to streamline the codebase.
2026-03-05 17:23:31 +05:30
Abhishek S Lal
5944a9cf06 feat/openapi sync (#7279)
* feat: implement OpenAPI Sync

* feat: enhance focus styles and error handling across components

- Added focus-visible styles for buttons and tags in Swagger and Modal components to improve accessibility.
- Updated ConnectSpecForm to ensure source URL is set only if the file path is valid.
- Enhanced clipboard copy functionality in SpecInfoCard with error handling and success notifications.
- Improved ExpandableEndpointRow to handle loading state more robustly.
- Refined SyncReviewPage to ensure correct filtering of updated endpoints.
- Updated file handling in OpenAPI Sync IPC to support both .yml and .yaml extensions.

* fix: improve filename sanitization in OpenAPI Sync IPC

- Updated filename sanitization logic to ensure proper handling of both `name` and `filename` properties, enhancing compatibility with various file formats.
- Adjusted the logic to derive the base name from the filename when necessary, ensuring consistent output for generated files.

* feat: enhance OpenAPI Sync tab with new overview and header components

- Introduced OverviewSection to display summary of collection and spec status, including total endpoints, in-sync counts, and pending updates.
- Added OpenAPISyncHeader for improved navigation and actions related to the OpenAPI spec.
- Updated CollectionStatusSection to better handle and display collection drift information.
- Refined styling for status banners and added new visual elements for better user experience.
- Enhanced tooltip functionality in Help component for improved accessibility.

* refactor: remove VisualDiffViewer components and add diff package

- Deleted VisualDiffViewer components including VisualDiffMeta, VisualDiffDocs, VisualDiffVars, and others to streamline the codebase.
- Introduced the 'diff' package in package-lock.json to enhance diff functionality.
- Updated utility functions to improve diff status handling and maintainability.
2026-03-05 02:25:08 +05:30
gopu-bruno
fb65edea9e fix: focus and text selection in workspace creation flow (#7363)
* fix: workspace creation rename focus

* refactor: remove duplicate focus/select timer from rename handler
2026-03-05 02:14:56 +05:30
gopu-bruno
84d8051c18 fix: show '+ Add request' when only transient items exist (#7361) 2026-03-04 23:05:06 +05:30
Chirag Chandrashekhar
0b7cd0e540 Revert "Performance/file parse and mount (#6975)" (#7360)
* Revert "Performance/file parse and mount (#6975)"

This reverts commit f76f487211.

* fix: import duplication

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

This reverts commit bf4af42a25.

---------

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

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

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

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

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

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

* revert: ping.bru

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

* refactor: use inline collection creation from workspace overview

* fix: improve inline collection creation UX from workspace overview

* fix: update E2E tests for inline collection creation flow

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

* fix: derive inline workspace/collection names from filesystem

* feat: inline workspace create form manage workspace

* feat: prefill create modal with name

* fix: minor code style fixes

---------

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

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

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

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

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

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

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

* fix: necessary changes for customizied keybindings to work

* fix: updated hotkeys provider

* fix: test cases for edit keybindings in preferences

* fix: removed old keyboard shortcuts test cases

* fix: resolved coderabbit coments

* fix: fixed move tabs test cases

* feat: provided customized keybindings shorcut for codemirror instacnces

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

* fix: resolved comments

* fix: resolved zoom issues

* fix: revert codemirror instacnces

* fix: handle codemirror instances shortcut in .Pass

* feat: integrate shorcut keys with codemirror instacneces

* fix: ui updates

* fix: updated shortcuts

* fix: test cases

* chore: revert `alt-enter` keybind

* chore: allow jest to replace esm spec in store

* chore: lint whitespace fix

---------

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

* fixes

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

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

* fix: debounce empty collection state to prevent flicker

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

Made-with: Cursor

---------

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

* fix: resolved comments

---------

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

* fix: resolved comments

* fix: resolved comments

---------

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

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

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

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

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

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

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

* fix: update error formatting and avoid undefined message

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

* refactor: use script wrapper utils and rename wrapper offsets

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

* refactor: rename tokenType to source in OpenCollection OAuth2 mapping

* refactor: rename tokenType to source in OAuth2 configuration

* chore: bump @opencollection/types to ~0.8.0

* fix: correct OAuth2 token type label in token viewer

* refactor: replace Dropdown with MenuDropdown in OAuth2 components

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

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

* fix(oauth2): clarify error handling comment

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

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

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

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

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

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

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

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

---------

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

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

* fixes

* adding: defaultPreferences

* fixes

* fix: tests

* fixes

* fix: test

* fix: test

* fixes

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

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

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

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

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

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

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

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

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

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

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

* fix: address PR review comments (#7187)

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

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

* empty commit

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

* empty commit

---------

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

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

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

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

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

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

* chore: lint

---------

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

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

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

---------

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

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

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

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

Fixes #4529

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

fix: support vars interpolation on mixed multi-part

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

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

refactor: use startsWith

feat: best effort for other multipart/* contentypes

* feat: enhance variable interpolation for multipart requests

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

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

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

---------

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

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: temporary check for tests

* fix: test cases for status and statusText

* chore: removed logs

* fix: test cases for response status and text

* fix: test cases for response status and text

* fix: resolved comments

* fix: openapi test import test cases

* chore: removed console logs

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

* fix: postman to bruno import

---------

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

* refactor: migrate defaultCollectionLocation to defaultLocation in preference.json

* refactor: resolveDefaultLocation function

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

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

* feat: add cache management to preferences

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

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

* refactor: update batch aggregation parameters for improved performance

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: pass mainWindow reference to parsedFileCacheStore initialization

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

* refactor: optimize getStats method in parsedFileCacheStore for performance

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

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

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

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

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

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

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

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

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

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

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

* fix: add LRU eviction when IndexedDB quota is exceeded

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: skip null parameters in Postman to Bruno conversion

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

* fix

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

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

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

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

* test: verify response status code is 200

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

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

* chore: seperate bru and opencollection by a flag

* fix: pass down format correctly

* fix: pass format option correctly in collection tests

* Add opencollection version for YAML format

Set opencollection version for YAML format.

---------

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

* fix: allow zoom controls using shortcuts

* fix: maintain consitency in zoom shortcuts and ui interface

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

* fix: moved percentageToZoomLevel function in bruno-common

* chore: abstractions

---------

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

feat: add tests and improve collection reordering functionality

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

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

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

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

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

* fix: handle invalid URLs in oauth2 callback redirect handler

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

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

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

---------

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

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

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

* chore: apply code rabbit fixes

* chore: adress review comment - keep changes minimal

* add additional test to test bru folder format

* agree with coderabbit, error handling is required

---------

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

empty commit

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

* chore: handle ansi escaping manually

* chore: avoid duplicate replacements

---------

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

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

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

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

* change transient directory path to userData/tmp/transient

---------

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

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

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

* fix: register isJson chai assertion in QuickJS test runtime

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

* fix: enable assertion chaining on isJson in QuickJS runtime

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

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

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

* Set default value of followredirects in axios-instance

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

---------

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

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

* add some doc about variable usage

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

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

fixes issue 5094 for requests

* fix: revert accidental changes

---------

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

* fix: corrected a typo

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

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

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

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

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

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

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

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

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

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

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

* fix: batch name tracking and git utility fixes

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

- Remove unused lodash import (git.js)

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

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

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

* fix: handle undefined color in environment objects

Don't export if `undefined`

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

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

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

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

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

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

* style: improve create scratch request button styling

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

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

* refactor: improve scratch collection detection with path registration

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

* refactor: use CreateTransientRequest for scratch requests in workspace tabs

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

* refactor: unify WorkspaceTabs with RequestTabs system

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

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

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

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

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

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

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

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

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

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

* chore: remove redundant comments and simplify code

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

* fix: improve SaveTransientRequest collection mounting and selection flow

* refactor: use WorkspaceOverview directly, remove WorkspaceHome wrapper

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

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

* refactor: separate scratch collection handling from openCollectionEvent

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

* test: add scratch requests test suite

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

* refactor: address code review feedback for scratch requests feature

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

* feat: add unified collection header with context switcher dropdown

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

* chore: allow pr to comment

* refactor: improve scratch requests test cleanup with closeAllTabs helper

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

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

* fix: cURL import NDJSON request body as text

* fix: cURL import NDJSON request body as text

* fix: resolved comments for body.text

* fix: add NDJSON content type detection

---------

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

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

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

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

* feat: implement collection variable management in Bruno

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

* feat: enhance collection variable translations in Bruno

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

* feat: expand API hints for variable management in Bruno

* fix: test cases

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

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

* rm: duplicates

* refactor: remove bru.visualize transformation and associated tests

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

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

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

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

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

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

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

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

* empty commit

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

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

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

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

* refactor: enhance handling of await expressions in cookie translations

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

* refactor: update cookie access translations to use hasCookie method

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: lint issues

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

* rm: toast for color change

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

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

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

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

* Mark test script errors as failed in runner
    and CLI

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

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

* Preserving stopExecution in test script error handler

---------

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

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

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

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

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

---------

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

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

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

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

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

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

* Preserving stopExecution in test script error handler

---------

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

5
.gitignore vendored
View File

@@ -49,6 +49,11 @@ bruno.iml
.idea
.vscode
.cursor
.claude
.codex
.agents
.agent
skills-lock.json
# Playwright
/blob-report/

View File

@@ -178,7 +178,8 @@ module.exports = runESMImports().then(() => defineConfig([
}
},
rules: {
'no-undef': 'error'
'no-undef': 'error',
'no-case-declarations': 'error'
}
},
{

10512
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -27,6 +27,7 @@
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",
"cookie": "0.7.1",
"diff2html": "^3.4.47",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
"fast-fuzzy": "^1.12.0",
@@ -38,7 +39,7 @@
"github-markdown-css": "^5.2.0",
"graphiql": "3.7.1",
"graphql": "^16.6.0",
"graphql-request": "^3.7.0",
"graphql-request": "4.2.0",
"hexy": "^0.3.5",
"httpsnippet": "^3.0.9",
"i18next": "24.1.2",
@@ -88,7 +89,7 @@
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "5.17.12",
"swagger-ui-react": "^5.31.0",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
@@ -101,7 +102,7 @@
"@babel/preset-react": "^7.27.1",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-node-polyfill": "1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
@@ -130,4 +131,4 @@
"form-data": "4.0.4"
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,713 @@
:host,
:root {
--d2h-bg-color: #fff;
--d2h-border-color: #ddd;
--d2h-dim-color: rgba(0, 0, 0, 0.3);
--d2h-line-border-color: #eee;
--d2h-file-header-bg-color: #f7f7f7;
--d2h-file-header-border-color: #d8d8d8;
--d2h-empty-placeholder-bg-color: #f1f1f1;
--d2h-empty-placeholder-border-color: #e1e1e1;
--d2h-selected-color: #c8e1ff;
--d2h-ins-bg-color: #dfd;
--d2h-ins-border-color: #b4e2b4;
--d2h-ins-highlight-bg-color: #97f295;
--d2h-ins-label-color: #399839;
--d2h-del-bg-color: #fee8e9;
--d2h-del-border-color: #e9aeae;
--d2h-del-highlight-bg-color: #ffb6ba;
--d2h-del-label-color: #c33;
--d2h-change-del-color: #fdf2d0;
--d2h-change-ins-color: #ded;
--d2h-info-bg-color: #f8fafd;
--d2h-info-border-color: #d5e4f2;
--d2h-change-label-color: #d0b44c;
--d2h-moved-label-color: #3572b0;
--d2h-dark-color: #e6edf3;
--d2h-dark-bg-color: #0d1117;
--d2h-dark-border-color: #30363d;
--d2h-dark-dim-color: #6e7681;
--d2h-dark-line-border-color: #21262d;
--d2h-dark-file-header-bg-color: #161b22;
--d2h-dark-file-header-border-color: #30363d;
--d2h-dark-empty-placeholder-bg-color: hsla(215, 8%, 47%, 0.1);
--d2h-dark-empty-placeholder-border-color: #30363d;
--d2h-dark-selected-color: rgba(56, 139, 253, 0.1);
--d2h-dark-ins-bg-color: rgba(46, 160, 67, 0.15);
--d2h-dark-ins-border-color: rgba(46, 160, 67, 0.4);
--d2h-dark-ins-highlight-bg-color: rgba(46, 160, 67, 0.4);
--d2h-dark-ins-label-color: #3fb950;
--d2h-dark-del-bg-color: rgba(248, 81, 73, 0.1);
--d2h-dark-del-border-color: rgba(248, 81, 73, 0.4);
--d2h-dark-del-highlight-bg-color: rgba(248, 81, 73, 0.4);
--d2h-dark-del-label-color: #f85149;
--d2h-dark-change-del-color: rgba(210, 153, 34, 0.2);
--d2h-dark-change-ins-color: rgba(46, 160, 67, 0.25);
--d2h-dark-info-bg-color: rgba(56, 139, 253, 0.1);
--d2h-dark-info-border-color: rgba(56, 139, 253, 0.4);
--d2h-dark-change-label-color: #d29922;
--d2h-dark-moved-label-color: #3572b0;
}
.d2h-wrapper {
text-align: left;
}
.d2h-file-header {
background-color: #f7f7f7;
background-color: var(--d2h-file-header-bg-color);
border-bottom: 1px solid #d8d8d8;
border-bottom: 1px solid var(--d2h-file-header-border-color);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-family: Source Sans Pro, Helvetica Neue, Helvetica, Arial, sans-serif;
height: 35px;
padding: 5px 10px;
}
.d2h-file-header.d2h-sticky-header {
position: sticky;
top: 0;
z-index: 1;
}
.d2h-file-stats {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 14px;
margin-left: auto;
}
.d2h-lines-added {
border: 1px solid #b4e2b4;
border: 1px solid var(--d2h-ins-border-color);
border-radius: 5px 0 0 5px;
color: #399839;
color: var(--d2h-ins-label-color);
padding: 2px;
text-align: right;
vertical-align: middle;
}
.d2h-lines-deleted {
border: 1px solid #e9aeae;
border: 1px solid var(--d2h-del-border-color);
border-radius: 0 5px 5px 0;
color: #c33;
color: var(--d2h-del-label-color);
margin-left: 1px;
padding: 2px;
text-align: left;
vertical-align: middle;
}
.d2h-file-name-wrapper {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
font-size: 15px;
width: 100%;
}
.d2h-file-name {
overflow-x: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.d2h-file-wrapper {
border: 1px solid #ddd;
border: 1px solid var(--d2h-border-color);
border-radius: 3px;
margin-bottom: 1em;
}
.d2h-file-collapse {
-webkit-box-pack: end;
-ms-flex-pack: end;
cursor: pointer;
display: none;
font-size: 12px;
justify-content: flex-end;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
border: 1px solid #ddd;
border: 1px solid var(--d2h-border-color);
border-radius: 3px;
padding: 4px 8px;
}
.d2h-file-collapse.d2h-selected {
background-color: #c8e1ff;
background-color: var(--d2h-selected-color);
}
.d2h-file-collapse-input {
margin: 0 4px 0 0;
}
.d2h-diff-table {
border-collapse: collapse;
font-family: Menlo, Consolas, monospace;
font-size: 13px;
width: 100%;
}
.d2h-files-diff {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
width: 100%;
}
.d2h-file-diff {
overflow-y: hidden;
}
.d2h-file-diff.d2h-d-none,
.d2h-files-diff.d2h-d-none {
display: none;
}
.d2h-file-side-diff {
display: inline-block;
overflow-x: scroll;
overflow-y: hidden;
width: 50%;
}
.d2h-code-line {
padding: 0 8em;
width: calc(100% - 16em);
}
.d2h-code-line,
.d2h-code-side-line {
display: inline-block;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
white-space: nowrap;
}
.d2h-code-side-line {
padding: 0 4.5em;
width: calc(100% - 9em);
}
.d2h-code-line-ctn {
background: none;
display: inline-block;
padding: 0;
word-wrap: normal;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
vertical-align: middle;
white-space: pre;
width: 100%;
}
.d2h-code-line del,
.d2h-code-side-line del {
background-color: #ffb6ba;
background-color: var(--d2h-del-highlight-bg-color);
}
.d2h-code-line del,
.d2h-code-line ins,
.d2h-code-side-line del,
.d2h-code-side-line ins {
border-radius: 0.2em;
display: inline-block;
margin-top: -1px;
-webkit-text-decoration: none;
text-decoration: none;
}
.d2h-code-line ins,
.d2h-code-side-line ins {
background-color: #97f295;
background-color: var(--d2h-ins-highlight-bg-color);
text-align: left;
}
.d2h-code-line-prefix {
background: none;
display: inline;
padding: 0;
word-wrap: normal;
white-space: pre;
}
.line-num1 {
float: left;
}
.line-num1,
.line-num2 {
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
padding: 0 0.5em;
text-overflow: ellipsis;
width: 3.5em;
}
.line-num2 {
float: right;
}
.d2h-code-linenumber {
background-color: #fff;
background-color: var(--d2h-bg-color);
border: solid #eee;
border: solid var(--d2h-line-border-color);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: rgba(0, 0, 0, 0.3);
color: var(--d2h-dim-color);
cursor: pointer;
display: inline-block;
position: absolute;
text-align: right;
width: 7.5em;
}
.d2h-code-linenumber:after {
content: '\200b';
}
.d2h-code-side-linenumber {
background-color: #fff;
background-color: var(--d2h-bg-color);
border: solid #eee;
border: solid var(--d2h-line-border-color);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: rgba(0, 0, 0, 0.3);
color: var(--d2h-dim-color);
cursor: pointer;
display: inline-block;
overflow: hidden;
padding: 0 0.5em;
position: absolute;
text-align: right;
text-overflow: ellipsis;
width: 4em;
}
.d2h-code-side-linenumber:after {
content: '\200b';
}
.d2h-code-side-emptyplaceholder,
.d2h-emptyplaceholder {
background-color: #f1f1f1;
background-color: var(--d2h-empty-placeholder-bg-color);
border-color: #e1e1e1;
border-color: var(--d2h-empty-placeholder-border-color);
}
.d2h-code-line-prefix,
.d2h-code-linenumber,
.d2h-code-side-linenumber,
.d2h-emptyplaceholder {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.d2h-code-linenumber,
.d2h-code-side-linenumber {
direction: rtl;
}
.d2h-del {
background-color: #fee8e9;
background-color: var(--d2h-del-bg-color);
border-color: #e9aeae;
border-color: var(--d2h-del-border-color);
}
.d2h-ins {
background-color: #dfd;
background-color: var(--d2h-ins-bg-color);
border-color: #b4e2b4;
border-color: var(--d2h-ins-border-color);
}
.d2h-info {
background-color: #f8fafd;
background-color: var(--d2h-info-bg-color);
border-color: #d5e4f2;
border-color: var(--d2h-info-border-color);
color: rgba(0, 0, 0, 0.3);
color: var(--d2h-dim-color);
}
.d2h-file-diff .d2h-del.d2h-change {
background-color: #fdf2d0;
background-color: var(--d2h-change-del-color);
}
.d2h-file-diff .d2h-ins.d2h-change {
background-color: #ded;
background-color: var(--d2h-change-ins-color);
}
.d2h-file-list-wrapper {
margin-bottom: 10px;
}
.d2h-file-list-wrapper a {
-webkit-text-decoration: none;
text-decoration: none;
}
.d2h-file-list-wrapper a,
.d2h-file-list-wrapper a:visited {
color: #3572b0;
color: var(--d2h-moved-label-color);
}
.d2h-file-list-header {
text-align: left;
}
.d2h-file-list-title {
font-weight: 700;
}
.d2h-file-list-line {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
text-align: left;
}
.d2h-file-list {
display: block;
list-style: none;
margin: 0;
padding: 0;
}
.d2h-file-list > li {
border-bottom: 1px solid #ddd;
border-bottom: 1px solid var(--d2h-border-color);
margin: 0;
padding: 5px 10px;
}
.d2h-file-list > li:last-child {
border-bottom: none;
}
.d2h-file-switch {
cursor: pointer;
display: none;
font-size: 10px;
}
.d2h-icon {
margin-right: 10px;
vertical-align: middle;
fill: currentColor;
}
.d2h-deleted {
color: #c33;
color: var(--d2h-del-label-color);
}
.d2h-added {
color: #399839;
color: var(--d2h-ins-label-color);
}
.d2h-changed {
color: #d0b44c;
color: var(--d2h-change-label-color);
}
.d2h-moved {
color: #3572b0;
color: var(--d2h-moved-label-color);
}
.d2h-tag {
background-color: #fff;
background-color: var(--d2h-bg-color);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 10px;
margin-left: 5px;
padding: 0 2px;
}
.d2h-deleted-tag {
border: 1px solid #c33;
border: 1px solid var(--d2h-del-label-color);
}
.d2h-added-tag {
border: 1px solid #399839;
border: 1px solid var(--d2h-ins-label-color);
}
.d2h-changed-tag {
border: 1px solid #d0b44c;
border: 1px solid var(--d2h-change-label-color);
}
.d2h-moved-tag {
border: 1px solid #3572b0;
border: 1px solid var(--d2h-moved-label-color);
}
.d2h-dark-color-scheme {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
color: #e6edf3;
color: var(--d2h-dark-color);
}
.d2h-dark-color-scheme .d2h-file-header {
background-color: #161b22;
background-color: var(--d2h-dark-file-header-bg-color);
border-bottom: #30363d;
border-bottom: var(--d2h-dark-file-header-border-color);
}
.d2h-dark-color-scheme .d2h-lines-added {
border: 1px solid rgba(46, 160, 67, 0.4);
border: 1px solid var(--d2h-dark-ins-border-color);
color: #3fb950;
color: var(--d2h-dark-ins-label-color);
}
.d2h-dark-color-scheme .d2h-lines-deleted {
border: 1px solid rgba(248, 81, 73, 0.4);
border: 1px solid var(--d2h-dark-del-border-color);
color: #f85149;
color: var(--d2h-dark-del-label-color);
}
.d2h-dark-color-scheme .d2h-code-line del,
.d2h-dark-color-scheme .d2h-code-side-line del {
background-color: rgba(248, 81, 73, 0.4);
background-color: var(--d2h-dark-del-highlight-bg-color);
}
.d2h-dark-color-scheme .d2h-code-line ins,
.d2h-dark-color-scheme .d2h-code-side-line ins {
background-color: rgba(46, 160, 67, 0.4);
background-color: var(--d2h-dark-ins-highlight-bg-color);
}
.d2h-dark-color-scheme .d2h-diff-tbody {
border-color: #30363d;
border-color: var(--d2h-dark-border-color);
}
.d2h-dark-color-scheme .d2h-code-side-linenumber {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
border-color: #21262d;
border-color: var(--d2h-dark-line-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-dark-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,
.d2h-dark-color-scheme .d2h-files-diff .d2h-emptyplaceholder {
background-color: hsla(215, 8%, 47%, 0.1);
background-color: var(--d2h-dark-empty-placeholder-bg-color);
border-color: #30363d;
border-color: var(--d2h-dark-empty-placeholder-border-color);
}
.d2h-dark-color-scheme .d2h-code-linenumber {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
border-color: #21262d;
border-color: var(--d2h-dark-line-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-dark-color-scheme .d2h-del {
background-color: rgba(248, 81, 73, 0.1);
background-color: var(--d2h-dark-del-bg-color);
border-color: rgba(248, 81, 73, 0.4);
border-color: var(--d2h-dark-del-border-color);
}
.d2h-dark-color-scheme .d2h-ins {
background-color: rgba(46, 160, 67, 0.15);
background-color: var(--d2h-dark-ins-bg-color);
border-color: rgba(46, 160, 67, 0.4);
border-color: var(--d2h-dark-ins-border-color);
}
.d2h-dark-color-scheme .d2h-info {
background-color: rgba(56, 139, 253, 0.1);
background-color: var(--d2h-dark-info-bg-color);
border-color: rgba(56, 139, 253, 0.4);
border-color: var(--d2h-dark-info-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-dark-color-scheme .d2h-file-diff .d2h-del.d2h-change {
background-color: rgba(210, 153, 34, 0.2);
background-color: var(--d2h-dark-change-del-color);
}
.d2h-dark-color-scheme .d2h-file-diff .d2h-ins.d2h-change {
background-color: rgba(46, 160, 67, 0.25);
background-color: var(--d2h-dark-change-ins-color);
}
.d2h-dark-color-scheme .d2h-file-wrapper {
border: 1px solid #30363d;
border: 1px solid var(--d2h-dark-border-color);
}
.d2h-dark-color-scheme .d2h-file-collapse {
border: 1px solid #0d1117;
border: 1px solid var(--d2h-dark-bg-color);
}
.d2h-dark-color-scheme .d2h-file-collapse.d2h-selected {
background-color: rgba(56, 139, 253, 0.1);
background-color: var(--d2h-dark-selected-color);
}
.d2h-dark-color-scheme .d2h-file-list-wrapper a,
.d2h-dark-color-scheme .d2h-file-list-wrapper a:visited {
color: #3572b0;
color: var(--d2h-dark-moved-label-color);
}
.d2h-dark-color-scheme .d2h-file-list > li {
border-bottom: 1px solid #0d1117;
border-bottom: 1px solid var(--d2h-dark-bg-color);
}
.d2h-dark-color-scheme .d2h-deleted {
color: #f85149;
color: var(--d2h-dark-del-label-color);
}
.d2h-dark-color-scheme .d2h-added {
color: #3fb950;
color: var(--d2h-dark-ins-label-color);
}
.d2h-dark-color-scheme .d2h-changed {
color: #d29922;
color: var(--d2h-dark-change-label-color);
}
.d2h-dark-color-scheme .d2h-moved {
color: #3572b0;
color: var(--d2h-dark-moved-label-color);
}
.d2h-dark-color-scheme .d2h-tag {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
}
.d2h-dark-color-scheme .d2h-deleted-tag {
border: 1px solid #f85149;
border: 1px solid var(--d2h-dark-del-label-color);
}
.d2h-dark-color-scheme .d2h-added-tag {
border: 1px solid #3fb950;
border: 1px solid var(--d2h-dark-ins-label-color);
}
.d2h-dark-color-scheme .d2h-changed-tag {
border: 1px solid #d29922;
border: 1px solid var(--d2h-dark-change-label-color);
}
.d2h-dark-color-scheme .d2h-moved-tag {
border: 1px solid #3572b0;
border: 1px solid var(--d2h-dark-moved-label-color);
}
@media (prefers-color-scheme: dark) {
.d2h-auto-color-scheme {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
color: #e6edf3;
color: var(--d2h-dark-color);
}
.d2h-auto-color-scheme .d2h-file-header {
background-color: #161b22;
background-color: var(--d2h-dark-file-header-bg-color);
border-bottom: #30363d;
border-bottom: var(--d2h-dark-file-header-border-color);
}
.d2h-auto-color-scheme .d2h-lines-added {
border: 1px solid rgba(46, 160, 67, 0.4);
border: 1px solid var(--d2h-dark-ins-border-color);
color: #3fb950;
color: var(--d2h-dark-ins-label-color);
}
.d2h-auto-color-scheme .d2h-lines-deleted {
border: 1px solid rgba(248, 81, 73, 0.4);
border: 1px solid var(--d2h-dark-del-border-color);
color: #f85149;
color: var(--d2h-dark-del-label-color);
}
.d2h-auto-color-scheme .d2h-code-line del,
.d2h-auto-color-scheme .d2h-code-side-line del {
background-color: rgba(248, 81, 73, 0.4);
background-color: var(--d2h-dark-del-highlight-bg-color);
}
.d2h-auto-color-scheme .d2h-code-line ins,
.d2h-auto-color-scheme .d2h-code-side-line ins {
background-color: rgba(46, 160, 67, 0.4);
background-color: var(--d2h-dark-ins-highlight-bg-color);
}
.d2h-auto-color-scheme .d2h-diff-tbody {
border-color: #30363d;
border-color: var(--d2h-dark-border-color);
}
.d2h-auto-color-scheme .d2h-code-side-linenumber {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
border-color: #21262d;
border-color: var(--d2h-dark-line-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-auto-color-scheme .d2h-files-diff .d2h-code-side-emptyplaceholder,
.d2h-auto-color-scheme .d2h-files-diff .d2h-emptyplaceholder {
background-color: hsla(215, 8%, 47%, 0.1);
background-color: var(--d2h-dark-empty-placeholder-bg-color);
border-color: #30363d;
border-color: var(--d2h-dark-empty-placeholder-border-color);
}
.d2h-auto-color-scheme .d2h-code-linenumber {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
border-color: #21262d;
border-color: var(--d2h-dark-line-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-auto-color-scheme .d2h-del {
background-color: rgba(248, 81, 73, 0.1);
background-color: var(--d2h-dark-del-bg-color);
border-color: rgba(248, 81, 73, 0.4);
border-color: var(--d2h-dark-del-border-color);
}
.d2h-auto-color-scheme .d2h-ins {
background-color: rgba(46, 160, 67, 0.15);
background-color: var(--d2h-dark-ins-bg-color);
border-color: rgba(46, 160, 67, 0.4);
border-color: var(--d2h-dark-ins-border-color);
}
.d2h-auto-color-scheme .d2h-info {
background-color: rgba(56, 139, 253, 0.1);
background-color: var(--d2h-dark-info-bg-color);
border-color: rgba(56, 139, 253, 0.4);
border-color: var(--d2h-dark-info-border-color);
color: #6e7681;
color: var(--d2h-dark-dim-color);
}
.d2h-auto-color-scheme .d2h-file-diff .d2h-del.d2h-change {
background-color: rgba(210, 153, 34, 0.2);
background-color: var(--d2h-dark-change-del-color);
}
.d2h-auto-color-scheme .d2h-file-diff .d2h-ins.d2h-change {
background-color: rgba(46, 160, 67, 0.25);
background-color: var(--d2h-dark-change-ins-color);
}
.d2h-auto-color-scheme .d2h-file-wrapper {
border: 1px solid #30363d;
border: 1px solid var(--d2h-dark-border-color);
}
.d2h-auto-color-scheme .d2h-file-collapse {
border: 1px solid #0d1117;
border: 1px solid var(--d2h-dark-bg-color);
}
.d2h-auto-color-scheme .d2h-file-collapse.d2h-selected {
background-color: rgba(56, 139, 253, 0.1);
background-color: var(--d2h-dark-selected-color);
}
.d2h-auto-color-scheme .d2h-file-list-wrapper a,
.d2h-auto-color-scheme .d2h-file-list-wrapper a:visited {
color: #3572b0;
color: var(--d2h-dark-moved-label-color);
}
.d2h-auto-color-scheme .d2h-file-list > li {
border-bottom: 1px solid #0d1117;
border-bottom: 1px solid var(--d2h-dark-bg-color);
}
.d2h-dark-color-scheme .d2h-deleted {
color: #f85149;
color: var(--d2h-dark-del-label-color);
}
.d2h-auto-color-scheme .d2h-added {
color: #3fb950;
color: var(--d2h-dark-ins-label-color);
}
.d2h-auto-color-scheme .d2h-changed {
color: #d29922;
color: var(--d2h-dark-change-label-color);
}
.d2h-auto-color-scheme .d2h-moved {
color: #3572b0;
color: var(--d2h-dark-moved-label-color);
}
.d2h-auto-color-scheme .d2h-tag {
background-color: #0d1117;
background-color: var(--d2h-dark-bg-color);
}
.d2h-auto-color-scheme .d2h-deleted-tag {
border: 1px solid #f85149;
border: 1px solid var(--d2h-dark-del-label-color);
}
.d2h-auto-color-scheme .d2h-added-tag {
border: 1px solid #3fb950;
border: 1px solid var(--d2h-dark-ins-label-color);
}
.d2h-auto-color-scheme .d2h-changed-tag {
border: 1px solid #d29922;
border: 1px solid var(--d2h-dark-change-label-color);
}
.d2h-auto-color-scheme .d2h-moved-tag {
border: 1px solid #3572b0;
border: 1px solid var(--d2h-dark-moved-label-color);
}
}

View File

@@ -2,10 +2,11 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
div.CodeMirror {
height: calc(100vh - 4rem);
height: calc(100vh - 9rem);
background: ${(props) => props.theme.codemirror.bg};
border: solid 1px ${(props) => props.theme.codemirror.border};
font-family: ${(props) => (props.font ? props.font : 'default')};
font-size: ${(props) => props.theme.font.size.base};
line-break: anywhere;
}

View File

@@ -1,51 +0,0 @@
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from './CodeEditor/index';
import { IconDeviceFloppy } from '@tabler/icons';
import { saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
const FileEditor = ({ apiSpec }) => {
const dispatch = useDispatch();
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [content, setContent] = useState(apiSpec?.raw);
const onEdit = (value) => {
setContent(value);
};
const onSave = () => {
dispatch(saveApiSpecToFile({ uid: apiSpec?.uid, content }));
};
const hasChanges = Boolean(content != apiSpec?.raw);
const editorMode = 'yaml';
return (
<div className="flex flex-grow relative">
<CodeEditor
theme={displayedTheme}
value={content}
onEdit={onEdit}
onSave={onSave}
mode={editorMode}
font={get(preferences, 'font.codeFont', 'default')}
/>
<IconDeviceFloppy
onClick={onSave}
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer oapcity-100' : 'cursor-default opacity-50'
}`}
/>
</div>
);
};
export default FileEditor;

View File

@@ -2,15 +2,868 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
.swagger-root {
height: calc(100vh - 4rem);
border: solid 1px ${(props) => props.theme.codemirror.border};
height: calc(100vh - 7rem);
border-left: solid 1px ${(props) => props.theme.border.border1};
overflow-y: auto;
background: ${(props) => props.theme.bg};
padding-bottom: 20px;
&.dark {
.swagger-ui {
filter: invert(88%) hue-rotate(180deg);
/* ── Global reset ── */
.swagger-ui {
font-family: inherit;
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.text};
* {
border-color: ${(props) => props.theme.border.border1};
}
.swagger-ui .microlight {
filter: invert(100%) hue-rotate(180deg);
.auth-container {
padding: 0;
}
select {
box-shadow: none !important;
}
.wrapper {
padding: 0 20px;
max-width: none;
}
/* ── Info section ── */
.info {
margin: 16px 0 12px;
hgroup.main {
margin: 0;
}
.title {
font-size: 16px;
font-weight: 600;
color: ${(props) => props.theme.text};
small {
padding: 2px 6px !important;
font-size: 10px;
vertical-align: middle;
border-radius: 3px;
pre {
color: ${(props) => props.theme.text} !important;
font-size: 10px;
}
}
}
.base-url {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
.description {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
p, li {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
margin: 3px 0;
line-height: 1.5;
}
h1, h2, h3, h4, h5, h6 {
color: ${(props) => props.theme.text};
}
a {
color: ${(props) => props.theme.textLink};
}
}
}
/* Version / OAS badges */
.version-stamp span.version {
background: ${(props) => props.theme.border.border1} !important;
border: 1px solid ${(props) => props.theme.colors.text.muted} !important;
color: ${(props) => props.theme.text} !important;
font-size: 9px;
padding: 2px 6px;
border-radius: 3px;
}
.version-pragma {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
/* ── Tag section headings ── */
.opblock-tag-section {
.opblock-tag {
font-size: ${(props) => props.theme.font.size.md};
color: ${(props) => props.theme.text};
border-bottom: none;
padding: 0;
&:hover {
background: ${(props) => props.theme.background.mantle};
}
a {
color: ${(props) => props.theme.text} !important;
}
small {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
padding: 0 10px;
}
}
}
/* ── Operation blocks (GET, POST, PUT, DELETE, PATCH) ── */
.opblock {
margin: 0 0 8px;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.border.border1} !important;
background: ${(props) => props.theme.bg} !important;
box-shadow: none !important;
.opblock-summary {
padding: 6px 10px;
border: none !important;
background: transparent !important;
.opblock-summary-method {
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
min-width: 50px;
text-align: center;
border-radius: 3px;
}
.opblock-summary-path {
font-size: ${(props) => props.theme.font.size.sm};
a, span {
color: ${(props) => props.theme.text} !important;
}
}
.opblock-summary-description {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
.opblock-summary-control {
svg {
fill: ${(props) => props.theme.colors.text.muted};
width: 14px;
height: 14px;
}
}
}
.opblock-body {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg};
border-top: 1px solid ${(props) => props.theme.border.border1};
.opblock-description-wrapper,
.opblock-section {
p {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
}
.tab-header .tab-item {
color: ${(props) => props.theme.colors.text.muted};
&.active {
color: ${(props) => props.theme.text};
}
}
select {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
font-size: ${(props) => props.theme.font.size.xs};
padding: 2px 6px;
}
input[type="text"] {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
font-size: ${(props) => props.theme.font.size.sm};
}
}
}
/* Method badge colors — keep them but tone down */
.opblock.opblock-get .opblock-summary-method { background: #61affe; color: #fff; }
.opblock.opblock-post .opblock-summary-method { background: #49cc90; color: #fff; }
.opblock.opblock-put .opblock-summary-method { background: #fca130; color: #fff; }
.opblock.opblock-delete .opblock-summary-method { background: #f93e3e; color: #fff; }
.opblock.opblock-patch .opblock-summary-method { background: #50e3c2; color: #000; }
/* Lock / authorization icons */
.authorization__btn {
svg {
fill: ${(props) => props.theme.colors.text.muted};
width: 14px;
height: 14px;
}
}
/* ── Tables ── */
table {
font-size: ${(props) => props.theme.font.size.sm};
thead {
tr {
th {
font-size: ${(props) => props.theme.font.size.xs} !important;
color: ${(props) => props.theme.colors.text.muted} !important;
border-bottom: 1px solid ${(props) => props.theme.border.border1} !important;
padding: 6px 0;
}
}
}
td {
padding: 6px 0;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
color: ${(props) => props.theme.text};
}
}
.parameter__name {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
&.required::after {
color: ${(props) => props.theme.colors.text.danger || '#c0392b'};
font-size: ${(props) => props.theme.font.size.xs};
}
}
.parameter__type {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
.parameter__in {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
/* ── Models / Schemas ── */
section.models {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
background: ${(props) => props.theme.bg};
padding-bottom: 0px;
margin-bottom: 40px;
margin-top: 8px;
h4 {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
border-bottom: none;
padding: 6px 10px;
margin: 0;
svg {
fill: ${(props) => props.theme.colors.text.muted};
width: 16px;
height: 16px;
}
}
.model-container {
background: ${(props) => props.theme.bg} !important;
margin: 0;
padding: 4px 8px;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
&:last-child {
border-bottom: none;
}
.model-box {
background: ${(props) => props.theme.bg} !important;
padding: 2px 0;
}
}
}
.model {
font-size: 11px;
color: ${(props) => props.theme.text};
line-height: 1.4;
.prop-type {
color: ${(props) => props.theme.textLink};
font-size: 11px;
}
.prop-format {
color: ${(props) => props.theme.colors.text.muted};
font-size: 10px;
}
span.prop-enum {
display: block;
color: ${(props) => props.theme.colors.text.muted};
font-size: 10px;
}
}
.model-example {
.tab li {
color: ${(props) => props.theme.colors.text.muted} !important;
}
}
/* Model expand/collapse toggle */
.model-toggle {
cursor: pointer;
font-size: 10px;
color: ${(props) => props.theme.colors.text.muted};
&::after {
color: ${(props) => props.theme.colors.text.muted};
}
}
/* Model box inner styling */
.model-box {
background: ${(props) => props.theme.bg} !important;
color: ${(props) => props.theme.text};
}
/* Inner model details */
.inner-object {
color: ${(props) => props.theme.text};
}
/* Model title (schema name) */
.model-title {
color: ${(props) => props.theme.text};
font-size: 12px;
font-weight: 600;
}
/* ── JSON Schema 2020-12 (OpenAPI 3.1) schema overrides ── */
.json-schema-2020-12-accordion,
.json-schema-2020-12-expand-deep-button,
section.models h4 button,
.model-box button,
.models-control,
.opblock-summary,
.opblock-summary-control,
.opblock-tag {
outline: none !important;
box-shadow: none !important;
}
button:focus-visible,
.opblock-summary:focus-visible,
.opblock-tag:focus-visible,
.models-control:focus-visible {
outline: 2px solid ${(props) => props.theme.textLink} !important;
outline-offset: 2px;
}
.json-schema-2020-12__title {
font-size: 12px !important;
font-weight: 600;
color: ${(props) => props.theme.text} !important;
}
.json-schema-2020-12-head {
padding: 4px 8px !important;
background: ${(props) => props.theme.bg} !important;
.json-schema-2020-12-accordion {
padding: 0 !important;
color: ${(props) => props.theme.text} !important;
background: transparent !important;
}
/* chevron / arrow icon */
.json-schema-2020-12-accordion__icon {
fill: ${(props) => props.theme.colors.text.muted} !important;
}
button.json-schema-2020-12-expand-deep-button {
font-size: 10px !important;
color: ${(props) => props.theme.colors.text.muted} !important;
background: transparent !important;
padding: 0 4px !important;
}
strong.json-schema-2020-12__attribute--primary {
font-size: 11px !important;
color: ${(props) => props.theme.textLink} !important;
font-weight: normal;
}
}
.json-schema-2020-12-body {
font-size: 11px !important;
margin-left: 16px;
color: ${(props) => props.theme.text} !important;
.json-schema-2020-12-property {
margin-left: 8px;
color: ${(props) => props.theme.text} !important;
border-color: ${(props) => props.theme.border.border1} !important;
}
/* property names */
.json-schema-2020-12__title {
font-size: 11px !important;
font-weight: normal;
color: ${(props) => props.theme.text} !important;
}
/* type badges inside expanded schema */
strong.json-schema-2020-12__attribute--primary {
font-size: 10px !important;
color: ${(props) => props.theme.textLink} !important;
font-weight: normal;
}
strong.json-schema-2020-12__attribute {
font-size: 10px !important;
color: ${(props) => props.theme.colors.text.muted} !important;
font-weight: normal;
}
}
.json-schema-2020-12 {
font-size: 11px !important;
margin: 0 !important;
width: 100%;
height: 100%;
color: ${(props) => props.theme.text} !important;
background: ${(props) => props.theme.bg} !important;
}
/* JSON viewer (Examples section inside schema properties) */
.json-schema-2020-12-json-viewer {
background: transparent !important;
color: ${(props) => props.theme.text} !important;
}
.json-schema-2020-12-json-viewer__name {
color: ${(props) => props.theme.text} !important;
}
.json-schema-2020-12-json-viewer__name--secondary {
color: ${(props) => props.theme.colors.text.muted} !important;
font-weight: normal !important;
}
.json-schema-2020-12-json-viewer__value {
color: ${(props) => props.theme.text} !important;
}
.json-schema-2020-12-json-viewer__value--secondary {
color: ${(props) => props.theme.colors.text.subtext0} !important;
}
.json-schema-2020-12-json-viewer__value--string,
.json-schema-2020-12-json-viewer__value--string.json-schema-2020-12-json-viewer__value--secondary {
color: ${(props) => props.theme.colors.text.green} !important;
}
.json-schema-2020-12-json-viewer__value--number,
.json-schema-2020-12-json-viewer__value--bigint,
.json-schema-2020-12-json-viewer__value--number.json-schema-2020-12-json-viewer__value--secondary,
.json-schema-2020-12-json-viewer__value--bigint.json-schema-2020-12-json-viewer__value--secondary {
color: ${(props) => props.theme.textLink} !important;
}
.json-schema-2020-12-json-viewer__value--boolean,
.json-schema-2020-12-json-viewer__value--boolean.json-schema-2020-12-json-viewer__value--secondary {
color: ${(props) => props.theme.colors.text.warning} !important;
}
.json-schema-2020-12-json-viewer__value--null,
.json-schema-2020-12-json-viewer__value--undefined {
color: ${(props) => props.theme.colors.text.muted} !important;
}
/* enum/keyword example values container */
.json-schema-2020-12-keyword--examples,
[data-json-schema-keyword="examples"] {
color: ${(props) => props.theme.text} !important;
}
/* Model collapse/expand all link */
span.model-toggle {
color: ${(props) => props.theme.colors.text.muted};
font-size: 10px;
}
/* Brace styling in models */
.brace-open, .brace-close {
color: ${(props) => props.theme.colors.text.muted};
font-size: 11px;
}
/* ── Code / Response blocks ── */
.microlight {
background: ${(props) => props.theme.codemirror.bg} !important;
color: ${(props) => props.theme.text} !important;
font-size: ${(props) => props.theme.font.size.xs};
border-radius: 4px;
padding: 8px;
border: 1px solid ${(props) => props.theme.border.border1};
}
.highlight-code {
background: ${(props) => props.theme.codemirror.bg} !important;
> .microlight {
border: none;
}
}
pre {
color: ${(props) => props.theme.text};
font-size: ${(props) => props.theme.font.size.xs};
border-radius: 4px;
}
.response-col_status {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
}
.response-col_description {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.responses-inner {
h4, h5 {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
}
}
/* ── Buttons ── */
.btn {
font-size: ${(props) => props.theme.font.size.xs};
border-radius: 4px;
box-shadow: none !important;
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.border.border1};
background: transparent;
}
.btn.authorize {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.border.border1};
background: transparent;
svg {
fill: ${(props) => props.theme.text};
}
span {
color: ${(props) => props.theme.text};
}
}
.btn.execute {
background: ${(props) => props.theme.primary?.solid || props.theme.textLink};
color: #fff;
border-color: transparent;
}
.btn-group {
.btn {
background: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
}
}
/* ── Links ── */
a {
color: ${(props) => props.theme.textLink};
}
/* ── Servers / Scheme container ── */
.scheme-container {
background: ${(props) => props.theme.background.mantle} !important;
border-top: 1px solid ${(props) => props.theme.border.border1};
border-bottom: 1px solid ${(props) => props.theme.border.border1};
padding: 10px;
box-shadow: none !important;
.schemes-title {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
select {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
padding: 4px 8px;
}
}
/* ── SVGs / icons ── */
svg {
fill: ${(props) => props.theme.colors.text.muted};
}
svg.arrow {
fill: ${(props) => props.theme.text};
width: 12px;
height: 12px;
margin-left: 4px;
}
.expand-operation svg {
fill: ${(props) => props.theme.colors.text.muted};
width: 14px;
height: 14px;
}
/* ── Misc / catch-all ── */
.loading-container .loading::after {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
.renderedMarkdown p {
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
.opblock-section-header {
background: ${(props) => props.theme.background.mantle} !important;
box-shadow: none !important;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
padding: 6px 10px;
h4 {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
}
label {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.colors.text.muted};
}
}
.copy-to-clipboard {
button {
background: ${(props) => props.theme.background.mantle};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
}
}
/* Dialog / modal overrides */
.dialog-ux {
.modal-ux {
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 6px;
color: ${(props) => props.theme.text};
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
.modal-ux-header {
border-bottom: 1px solid ${(props) => props.theme.border.border1};
padding: 12px 0px;
h3 {
font-size: ${(props) => props.theme.font.size.md};
font-weight: 600;
color: ${(props) => props.theme.text};
}
.close-modal {
opacity: 0.6;
&:hover { opacity: 1; }
svg { fill: ${(props) => props.theme.text}; }
}
}
.modal-ux-content {
color: ${(props) => props.theme.text};
padding: 12px 16px;
p {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
/* Section headings like "api_key (apiKey)" */
h4, h5, h6 {
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
color: ${(props) => props.theme.textLink};
margin: 12px 0 6px;
}
/* Labels: "Name:", "In:", "Flow:", "Value:", etc. */
label {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
> span {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
}
/* "Scopes:" heading */
.scopes h2 {
font-size: ${(props) => props.theme.font.size.sm} !important;
font-weight: 500;
color: ${(props) => props.theme.text} !important;
}
/* Scope item name + description */
.scopes .checkbox {
p.name {
font-size: ${(props) => props.theme.font.size.sm} !important;
color: ${(props) => props.theme.text} !important;
font-weight: 500;
margin: 0;
}
p.description {
font-size: ${(props) => props.theme.font.size.xs} !important;
color: ${(props) => props.theme.colors.text.muted} !important;
margin: 0;
}
}
/* Text inputs */
input[type="text"],
input[type="password"],
input[type="email"] {
background: ${(props) => props.theme.background.mantle} !important;
color: ${(props) => props.theme.text} !important;
border: 1px solid ${(props) => props.theme.border.border1} !important;
border-radius: 4px !important;
font-size: ${(props) => props.theme.font.size.sm} !important;
padding: 6px 10px !important;
outline: none !important;
box-shadow: none !important;
&:focus {
border-color: ${(props) => props.theme.textLink} !important;
outline: none !important;
box-shadow: none !important;
}
}
/* Checkboxes — custom styled to match theme */
input[type="checkbox"] {
appearance: none !important;
-webkit-appearance: none !important;
width: 14px !important;
height: 14px !important;
min-width: 14px;
border: 1px solid ${(props) => props.theme.border.border1} !important;
border-radius: 3px !important;
background: ${(props) => props.theme.background.mantle} !important;
cursor: pointer;
position: relative;
vertical-align: middle;
&:checked {
background: ${(props) => props.theme.textLink} !important;
border-color: ${(props) => props.theme.textLink} !important;
&::after {
content: '';
position: absolute;
left: 3px;
top: 1px;
width: 5px;
height: 8px;
border: 2px solid #fff;
border-top: none;
border-left: none;
transform: rotate(45deg);
}
}
}
/* "select all / select none" links */
a {
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.textLink};
}
/* Dividers between auth sections */
hr {
border-color: ${(props) => props.theme.border.border1};
margin: 12px 0;
}
/* Authorize / Close buttons */
.btn-done,
.auth-btn-wrapper .btn {
font-size: ${(props) => props.theme.font.size.sm};
border-radius: 4px;
padding: 6px 16px;
border: 1px solid ${(props) => props.theme.border.border1};
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
outline: none !important;
box-shadow: none !important;
&:hover {
background: ${(props) => props.theme.background.mantle};
}
&.modal-btn-operation {
background: ${(props) => props.theme.textLink};
color: #fff;
border-color: transparent;
&:hover {
opacity: 0.9;
}
}
}
}
}
.backdrop-ux {
background: rgba(0, 0, 0, 0.5);
}
}
}
}

View File

@@ -1,16 +1,11 @@
import SwaggerUI from 'swagger-ui-react';
import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
const Swagger = ({ string }) => {
const { displayedTheme } = useTheme();
console.log('string', string);
const Swagger = ({ spec }) => {
return (
<StyledWrapper>
<div className={`swagger-root w-full overflow-y-scroll ${displayedTheme}`}>
<SwaggerUI spec={string} />
<div className="swagger-root w-full">
<SwaggerUI spec={spec} />
</div>
</StyledWrapper>
);

View File

@@ -0,0 +1,71 @@
import React, { useState, useEffect, Suspense } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { IconDeviceFloppy } from '@tabler/icons';
import CodeEditor from './FileEditor/CodeEditor/index';
import Swagger from './Renderers/Swagger';
/**
* Shared split-pane spec viewer: CodeEditor (left) + Swagger preview (right).
*
* Props:
* - content (string) The spec content (YAML/JSON string)
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
* - onSave (function) Called with current editor content on save (editable mode only)
*/
const SpecViewer = ({ content, readOnly, onSave }) => {
const { displayedTheme, theme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [editorContent, setEditorContent] = useState(content);
// Sync editor when saved content changes from outside (e.g. after save completes)
useEffect(() => {
setEditorContent(content);
}, [content]);
const hasChanges = !readOnly && editorContent !== content;
const handleSave = () => {
if (onSave) onSave(editorContent);
};
return (
<section className="main flex flex-grow pl-4 relative">
<div className="w-full grid grid-cols-2">
<div className="col-span-1">
<div className="flex flex-grow relative">
<CodeEditor
theme={displayedTheme}
value={readOnly ? content : editorContent}
readOnly={readOnly ? 'nocursor' : false}
onEdit={readOnly ? undefined : (val) => setEditorContent(val)}
onSave={readOnly ? undefined : handleSave}
mode="yaml"
font={get(preferences, 'font.codeFont', 'default')}
/>
{!readOnly && onSave && (
<IconDeviceFloppy
onClick={handleSave}
color={hasChanges ? theme.draftColor : theme.requestTabs.icon.color}
strokeWidth={1.5}
size={22}
className={`absolute right-0 top-0 m-4 ${
hasChanges ? 'cursor-pointer opacity-100' : 'cursor-default opacity-50'
}`}
/>
)}
</div>
</div>
<div className="col-span-1">
<Suspense fallback="">
<Swagger spec={content} />
</Suspense>
</div>
</div>
</section>
);
};
export default SpecViewer;

View File

@@ -3,13 +3,11 @@ import find from 'lodash/find';
import { useSelector, useDispatch } from 'react-redux';
import { IconFileCode, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import FileEditor from './FileEditor';
import SpecViewer from './SpecViewer';
import Dropdown from 'components/Dropdown';
import { openApiSpec } from 'providers/ReduxStore/slices/apiSpec';
import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import { Suspense } from 'react';
import Swagger from './Renderers/Swagger';
import toast from 'react-hot-toast';
const ApiSpecPanel = () => {
@@ -78,18 +76,10 @@ const ApiSpecPanel = () => {
</Dropdown>
</div>
</div>
<section className="main flex flex-grow px-4 relative">
<div className="w-full grid grid-cols-2">
<div className="col-span-1">
<FileEditor apiSpec={apiSpec} />
</div>
<div className="col-span-1">
<Suspense fallback="">
<Swagger string={raw} />
</Suspense>
</div>
</div>
</section>
<SpecViewer
content={raw}
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
/>
</StyledWrapper>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,6 +96,18 @@ const Wrapper = styled.div`
max-width: 200px !important;
}
.name-cell-wrapper {
position: relative;
width: 100%;
}
.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

@@ -50,6 +50,7 @@ const EnvironmentVariablesTable = ({
const [tableHeight, setTableHeight] = useState(MIN_H);
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
const [resizing, setResizing] = useState(null);
const [pinnedData, setPinnedData] = useState({ query: '', uids: new Set() });
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -92,6 +93,13 @@ const EnvironmentVariablesTable = ({
setTableHeight(h);
}, []);
const handleRowFocus = useCallback((uid) => {
setPinnedData((prev) => ({
query: searchQuery,
uids: prev.query === searchQuery ? new Set([...prev.uids, uid]) : new Set([uid])
}));
}, [searchQuery]);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
@@ -194,6 +202,10 @@ const EnvironmentVariablesTable = ({
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
}, [environment.variables]);
useEffect(() => {
setPinnedData({ query: '', uids: new Set() });
}, [savedValuesJson]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
@@ -347,6 +359,7 @@ const EnvironmentVariablesTable = ({
onSave(cloneDeep(variablesToSave))
.then(() => {
toast.success('Changes saved successfully');
onDraftClear();
const newValues = [
...variablesToSave,
{
@@ -365,7 +378,7 @@ const EnvironmentVariablesTable = ({
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, setIsModified]);
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
const handleReset = useCallback(() => {
const originalVars = environment.variables || [];
@@ -407,132 +420,157 @@ 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;
}
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
return allVariables.filter(({ variable }) => {
if (effectivePins.has(variable.uid)) return true;
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
const valueText
= typeof variable.value === 'string'
? variable.value
: typeof variable.value === 'number' || typeof variable.value === 'boolean'
? String(variable.value)
: '';
const valueMatch = valueText.toLowerCase().includes(query);
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery]);
}, [formik.values, searchQuery, pinnedData]);
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;
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
</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' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
onBlur={() => {
handleNameBlur(actualIndex);
}}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
</div>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
style={{ width: columnWidths.value }}
>
<div
className="overflow-hidden grow w-full relative"
onFocus={() => handleRowFocus(variable.uid)}
>
<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);
// 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={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<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

@@ -4,6 +4,7 @@ import { uuid } from 'utils/common';
import { useFormik } from 'formik';
import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import useDeferredLoading from 'hooks/useDeferredLoading';
import StyledWrapper from './StyledWrapper';
import DotEnvTableView from './DotEnvTableView';
@@ -31,6 +32,7 @@ const DotEnvFileEditor = ({
const [rawValue, setRawValue] = useState(initialRawValue);
const [prevViewMode, setPrevViewMode] = useState(viewMode);
const [isSaving, setIsSaving] = useState(false);
const showSaving = useDeferredLoading(isSaving, 200);
const formikRef = useRef(null);
@@ -311,7 +313,7 @@ const DotEnvFileEditor = ({
onChange={handleRawChange}
onSave={handleSaveRaw}
onReset={handleReset}
isSaving={isSaving}
isSaving={showSaving}
/>
</StyledWrapper>
);
@@ -335,7 +337,7 @@ const DotEnvFileEditor = ({
onRemoveVar={handleRemoveVar}
onSave={handleSave}
onReset={handleReset}
isSaving={isSaving}
isSaving={showSaving}
/>
</StyledWrapper>
);

View File

@@ -9,6 +9,7 @@ const Wrapper = styled.div`
border: 1px solid ${(props) => props.theme.app.collection.toolbar.environmentSelector.border};
line-height: 1rem;
transition: all 0.15s ease;
height: 24px;
&:hover {
border-color: ${(props) => props.theme.app.collection.toolbar.environmentSelector.hoverBorder};
@@ -73,7 +74,7 @@ const Wrapper = styled.div`
border-top: 0.0625rem solid ${(props) => props.theme.dropdown.separator};
z-index: 10;
margin: 0;
&:hover {
background-color: ${(props) => props.theme.dropdown.bg + ' !important'};
}
@@ -119,7 +120,7 @@ const Wrapper = styled.div`
.environment-list {
flex: 1;
overflow-y: auto;
max-height: calc(75vh - 8rem);
max-height: calc(75vh - 8rem);
padding-bottom: 2.625rem;
}

View File

@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 8px 20px;
padding: 9px 20px 8px 20px;
flex-shrink: 0;
.title {

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;
border-radius: 6px;
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};
}
}
}
@@ -104,7 +110,15 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0 8px;
padding: 8px;
}
.section-header {
margin-inline: 4px !important;
padding-left: 6px !important;
border-radius: 6px ;
padding-right: 3px !important;
padding-block: 4px !important;
}
.environments-list {
@@ -130,6 +144,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 {
@@ -143,7 +161,7 @@ const StyledWrapper = styled.div`
font-size: 13px;
color: ${(props) => props.theme.text};
cursor: pointer;
border-radius: 5px;
border-radius: 6px;
transition: background 0.15s ease;
.environment-name {

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,14 @@ 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 envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
const [newEnvName, setNewEnvName] = useState('');
@@ -65,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 coll = state.collections.collections.find((c) => c.uid === collection?.uid);
return coll?.dotEnvFiles || EMPTY_ARRAY;
@@ -73,6 +83,8 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const environmentsDraftUid = collection?.environmentsDraft?.environmentUid;
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
@@ -81,10 +93,10 @@ const EnvironmentList = ({
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
} else if (environmentsDraftUid?.startsWith('dotenv:')) {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, [dispatch, collection.uid, selectedDotEnvFile]);
}, [dispatch, collection.uid, selectedDotEnvFile, environmentsDraftUid]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
@@ -497,6 +509,12 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -531,20 +549,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,18 +557,67 @@ const EnvironmentList = ({
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleCreateEnvClick();
}}
title="Create environment"
>
<IconPlus size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleImportClick()} title="Import environment">
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleImportClick();
}}
title="Import environment"
>
<IconDownload size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleExportClick()} title="Export environment">
<button
type="button"
className="btn-action"
onClick={() => {
if (!environmentsExpanded) setEnvironmentsExpanded(true);
handleExportClick();
}}
title="Export environment"
>
<IconUpload size={14} strokeWidth={1.5} />
</button>
</>
)}
>
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button
className="env-list-search-clear"
title="Clear search"
onClick={() => setSearchText('')}
onMouseDown={(e) => e.preventDefault()}
>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { IconChevronDown, IconChevronRight } from '@tabler/icons';
const CollapsibleDiffRow = ({ title, isCollapsed, onToggle, oldContent, newContent, hasOldContent, hasNewContent }) => {
if (!hasOldContent && !hasNewContent) {
return null;
}
return (
<div className="diff-row">
<div className="diff-row-header" onClick={onToggle}>
<span className="collapse-toggle">
{isCollapsed ? (
<IconChevronRight size={14} strokeWidth={2} />
) : (
<IconChevronDown size={14} strokeWidth={2} />
)}
</span>
<span className="diff-row-title">{title}</span>
</div>
{!isCollapsed && (
<div className="diff-row-content">
<div className="diff-row-pane old">
{hasOldContent ? oldContent : <div className="empty-placeholder" />}
</div>
<div className="diff-row-pane new">
{hasNewContent ? newContent : <div className="empty-placeholder" />}
</div>
</div>
)}
</div>
);
};
export default CollapsibleDiffRow;

View File

@@ -0,0 +1,199 @@
import React, { useMemo } from 'react';
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
const AUTH_TYPE_LABELS = {
awsv4: 'AWS Signature v4',
basic: 'Basic Auth',
bearer: 'Bearer Token',
digest: 'Digest Auth',
ntlm: 'NTLM',
oauth2: 'OAuth 2.0',
wsse: 'WSSE',
apikey: 'API Key'
};
const AUTH_FIELD_LABELS = {
// AWS v4
accessKeyId: 'Access Key ID',
secretAccessKey: 'Secret Access Key',
sessionToken: 'Session Token',
service: 'Service',
region: 'Region',
profileName: 'Profile Name',
// Basic/Digest/NTLM/WSSE
username: 'Username',
password: 'Password',
domain: 'Domain',
// Bearer
token: 'Token',
// API Key
key: 'Key',
value: 'Value',
placement: 'Placement',
// OAuth2
grantType: 'Grant Type',
callbackUrl: 'Callback URL',
authorizationUrl: 'Authorization URL',
accessTokenUrl: 'Access Token URL',
refreshTokenUrl: 'Refresh Token URL',
clientId: 'Client ID',
clientSecret: 'Client Secret',
scope: 'Scope',
state: 'State',
pkce: 'PKCE',
credentialsPlacement: 'Credentials Placement',
credentialsId: 'Credentials ID',
tokenPlacement: 'Token Placement',
tokenHeaderPrefix: 'Token Header Prefix',
tokenQueryKey: 'Token Query Key',
autoFetchToken: 'Auto Fetch Token',
autoRefreshToken: 'Auto Refresh Token'
};
const VisualDiffAuth = ({ oldData, newData, showSide }) => {
const oldAuth = get(oldData, 'request.auth', {});
const newAuth = get(newData, 'request.auth', {});
const currentAuth = showSide === 'old' ? oldAuth : newAuth;
const otherAuth = showSide === 'old' ? newAuth : oldAuth;
const authTypes = useMemo(() => {
const types = new Set([...Object.keys(currentAuth), ...Object.keys(otherAuth)]);
types.delete('mode');
return Array.from(types);
}, [currentAuth, otherAuth]);
const authSections = useMemo(() => {
return authTypes.map((authType) => {
const rawCurrentConfig = currentAuth[authType];
const rawOtherConfig = otherAuth[authType];
const currentConfig = (typeof rawCurrentConfig === 'object' && rawCurrentConfig !== null) ? rawCurrentConfig : {};
const otherConfig = (typeof rawOtherConfig === 'object' && rawOtherConfig !== null) ? rawOtherConfig : {};
if (Object.keys(currentConfig).length === 0 && showSide === 'old') {
return null;
}
if (Object.keys(currentConfig).length === 0 && showSide === 'new') {
return null;
}
let sectionStatus = 'unchanged';
if (Object.keys(otherConfig).length === 0) {
sectionStatus = showSide === 'old' ? 'deleted' : 'added';
} else if (!isEqual(currentConfig, otherConfig)) {
sectionStatus = 'modified';
}
const allFields = new Set([...Object.keys(currentConfig), ...Object.keys(otherConfig)]);
const fields = Array.from(allFields).map((field) => {
const currentValue = currentConfig[field];
const otherValue = otherConfig[field];
let status = 'unchanged';
if (otherValue === undefined) {
status = showSide === 'old' ? 'deleted' : 'added';
} else if (currentValue !== otherValue) {
status = 'modified';
}
let displayValue = currentValue;
if (typeof displayValue === 'boolean') {
displayValue = displayValue ? 'true' : 'false';
} else if (displayValue === undefined || displayValue === null) {
displayValue = '';
}
return {
key: AUTH_FIELD_LABELS[field] || field,
value: String(displayValue),
status
};
});
return {
type: authType,
label: AUTH_TYPE_LABELS[authType] || authType,
status: sectionStatus,
fields
};
}).filter(Boolean);
}, [authTypes, currentAuth, otherAuth, showSide]);
const currentMode = currentAuth.mode;
const otherMode = otherAuth.mode;
const modeStatus = currentMode !== otherMode ? (otherMode === undefined ? (showSide === 'old' ? 'deleted' : 'added') : 'modified') : 'unchanged';
if (authSections.length === 0 && !currentMode) {
return null;
}
return (
<>
{currentMode && (
<div className="diff-section">
<table className="diff-table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th style={{ width: '40%' }}>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr className={modeStatus}>
<td>
{modeStatus !== 'unchanged' && (
<span className={`status-badge ${modeStatus}`}>
{modeStatus === 'added' ? 'A' : modeStatus === 'deleted' ? 'D' : 'M'}
</span>
)}
</td>
<td className="key-cell">Auth Mode</td>
<td className="value-cell">{AUTH_TYPE_LABELS[currentMode] || currentMode}</td>
</tr>
</tbody>
</table>
</div>
)}
{authSections.map((section) => (
<div key={section.type} className="diff-section">
<div className="diff-section-header">
<span>{section.label}</span>
{section.status !== 'unchanged' && (
<span className={`status-badge ${section.status}`}>
{section.status === 'added' ? 'A' : section.status === 'deleted' ? 'D' : 'M'}
</span>
)}
</div>
<table className="diff-table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th style={{ width: '40%' }}>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{section.fields.map((field, index) => (
<tr key={index} className={field.status}>
<td>
{field.status !== 'unchanged' && (
<span className={`status-badge ${field.status}`}>
{field.status === 'added' ? 'A' : field.status === 'deleted' ? 'D' : 'M'}
</span>
)}
</td>
<td className="key-cell">{field.key}</td>
<td className="value-cell">{field.value}</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</>
);
};
export default VisualDiffAuth;

View File

@@ -0,0 +1,353 @@
import React, { useMemo } from 'react';
import get from 'lodash/get';
import isEqual from 'lodash/isEqual';
import { computeLineDiffForOld, computeLineDiffForNew } from './utils/diffUtils';
const BODY_TYPE_LABELS = {
json: 'JSON',
text: 'Text',
xml: 'XML',
sparql: 'SPARQL',
graphql: 'GraphQL',
formUrlEncoded: 'Form URL Encoded',
multipartForm: 'Multipart Form',
file: 'File',
grpc: 'gRPC',
ws: 'WebSocket'
};
const TEXT_BODY_TYPES = ['json', 'text', 'xml', 'sparql'];
const FORM_BODY_TYPES = ['formUrlEncoded', 'multipartForm'];
const ALL_BODY_TYPES = Object.keys(BODY_TYPE_LABELS);
const VisualDiffBody = ({ oldData, newData, showSide }) => {
const oldBody = get(oldData, 'request.body', {});
const newBody = get(newData, 'request.body', {});
const currentBody = showSide === 'old' ? oldBody : newBody;
const otherBody = showSide === 'old' ? newBody : oldBody;
const bodyTypes = useMemo(() => {
const currentMode = currentBody.mode;
const otherMode = otherBody.mode;
// Collect body types that match either side's active mode
const relevantTypes = new Set();
if (currentMode && currentMode !== 'none') {
relevantTypes.add(currentMode);
}
if (otherMode && otherMode !== 'none') {
relevantTypes.add(otherMode);
}
// If neither side has a mode (legacy data), fall back to showing all defined types
if (relevantTypes.size === 0) {
return ALL_BODY_TYPES.filter((type) => {
const currentVal = currentBody[type];
const otherVal = otherBody[type];
return currentVal !== undefined || otherVal !== undefined;
});
}
// Only show body types that match the active mode on either side
return ALL_BODY_TYPES.filter((type) => {
if (!relevantTypes.has(type)) return false;
const currentVal = currentBody[type];
const otherVal = otherBody[type];
return currentVal !== undefined || otherVal !== undefined;
});
}, [currentBody, otherBody]);
const renderLineDiff = (segments) => {
return segments.map((segment, index) => (
<div key={index} className={`diff-line ${segment.status}`}>
{segment.text || '\u00A0'}
</div>
));
};
const renderFormData = (items, otherItems) => {
if (!items || items.length === 0) return null;
const otherItemMap = new Map();
(otherItems || []).forEach((item) => {
otherItemMap.set(item.name, item);
});
return (
<table className="diff-table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th className="checkbox-cell"></th>
<th style={{ width: '40%' }}>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{items.map((item, index) => {
const otherItem = otherItemMap.get(item.name);
let status = 'unchanged';
if (!otherItem) {
status = showSide === 'old' ? 'deleted' : 'added';
} else if (item.value !== otherItem.value || item.enabled !== otherItem.enabled) {
status = 'modified';
}
return (
<tr key={`${item.name}-${index}`} className={status}>
<td>
{status !== 'unchanged' && (
<span className={`status-badge ${status}`}>
{status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'}
</span>
)}
</td>
<td className="checkbox-cell">
<input
type="checkbox"
checked={item.enabled !== false}
readOnly
disabled
/>
</td>
<td className="key-cell">{item.name}</td>
<td className="value-cell">{item.value}</td>
</tr>
);
})}
</tbody>
</table>
);
};
const renderFileBody = (files, otherFiles) => {
if (!files || files.length === 0) return null;
const otherFileMap = new Map();
(otherFiles || []).forEach((f, idx) => {
otherFileMap.set(f.filePath || idx, f);
});
return (
<table className="diff-table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th className="checkbox-cell"></th>
<th>File Path</th>
<th style={{ width: '100px' }}>Content Type</th>
</tr>
</thead>
<tbody>
{files.map((file, index) => {
const otherFile = otherFileMap.get(file.filePath || index);
let status = 'unchanged';
if (!otherFile) {
status = showSide === 'old' ? 'deleted' : 'added';
} else if (file.filePath !== otherFile.filePath || file.contentType !== otherFile.contentType) {
status = 'modified';
}
return (
<tr key={index} className={status}>
<td>
{status !== 'unchanged' && (
<span className={`status-badge ${status}`}>
{status === 'added' ? 'A' : status === 'deleted' ? 'D' : 'M'}
</span>
)}
</td>
<td className="checkbox-cell">
<input type="checkbox" checked={file.selected !== false} readOnly disabled />
</td>
<td className="value-cell">{file.filePath}</td>
<td className="value-cell">{file.contentType || '-'}</td>
</tr>
);
})}
</tbody>
</table>
);
};
const renderMessageBody = (messages, otherMessages, typeLabel) => {
if (!messages || messages.length === 0) return null;
return messages.map((msg, index) => {
const otherMsg = (otherMessages || [])[index];
const contentDiff = showSide === 'old'
? computeLineDiffForOld(msg.content || '', otherMsg?.content || '')
: computeLineDiffForNew(otherMsg?.content || '', msg.content || '');
let msgStatus = 'unchanged';
if (!otherMsg) {
msgStatus = showSide === 'old' ? 'deleted' : 'added';
} else if (msg.name !== otherMsg.name || msg.type !== otherMsg.type) {
msgStatus = 'modified';
}
return (
<div key={index}>
<div className="diff-section-header">
<span>{typeLabel}: {msg.name || `Message ${index + 1}`}{msg.type ? ` (${msg.type})` : ''}</span>
{msgStatus !== 'unchanged' && (
<span className={`status-badge ${msgStatus}`}>
{msgStatus === 'added' ? 'A' : msgStatus === 'deleted' ? 'D' : 'M'}
</span>
)}
</div>
<div className="code-diff-content">{renderLineDiff(contentDiff)}</div>
</div>
);
});
};
const renderGraphqlBody = (graphql, otherGraphql) => {
const currentQuery = graphql?.query || '';
const otherQuery = otherGraphql?.query || '';
const currentVariables = graphql?.variables || '';
const otherVariables = otherGraphql?.variables || '';
const queryDiff = showSide === 'old'
? computeLineDiffForOld(currentQuery, otherQuery)
: computeLineDiffForNew(otherQuery, currentQuery);
const variablesDiff = showSide === 'old'
? computeLineDiffForOld(currentVariables, otherVariables)
: computeLineDiffForNew(otherVariables, currentVariables);
return (
<>
{(currentQuery || otherQuery) && (
<div>
<div className="diff-section-header">Query</div>
<div className="code-diff-content">{renderLineDiff(queryDiff)}</div>
</div>
)}
{(currentVariables || otherVariables) && (
<div>
<div className="diff-section-header">Variables</div>
<div className="code-diff-content">{renderLineDiff(variablesDiff)}</div>
</div>
)}
</>
);
};
const renderTextBody = (currentContent, otherContent) => {
const diffSegments = showSide === 'old'
? computeLineDiffForOld(currentContent || '', otherContent || '')
: computeLineDiffForNew(otherContent || '', currentContent || '');
return (
<div className="code-diff-content">
{renderLineDiff(diffSegments)}
</div>
);
};
const renderBodyType = (type) => {
const currentVal = currentBody[type];
const otherVal = otherBody[type];
if (currentVal === undefined && otherVal === undefined) return null;
// For text-based body types
if (TEXT_BODY_TYPES.includes(type)) {
if (!currentVal) return null;
return renderTextBody(currentVal, otherVal);
}
// For form data types
if (FORM_BODY_TYPES.includes(type)) {
return renderFormData(currentVal, otherVal);
}
// GraphQL
if (type === 'graphql') {
return renderGraphqlBody(currentVal, otherVal);
}
// File
if (type === 'file') {
return renderFileBody(currentVal, otherVal);
}
// gRPC
if (type === 'grpc') {
return renderMessageBody(currentVal, otherVal, 'gRPC');
}
// WebSocket
if (type === 'ws') {
return renderMessageBody(currentVal, otherVal, 'WebSocket');
}
return null;
};
// Show body mode if present
const currentMode = currentBody.mode;
const otherMode = otherBody.mode;
const modeStatus = currentMode !== otherMode ? (otherMode === undefined ? (showSide === 'old' ? 'deleted' : 'added') : 'modified') : 'unchanged';
if (bodyTypes.length === 0 && !currentMode) {
return null;
}
return (
<>
{currentMode && (
<div className="diff-section">
<table className="diff-table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th style={{ width: '40%' }}>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr className={modeStatus}>
<td>
{modeStatus !== 'unchanged' && (
<span className={`status-badge ${modeStatus}`}>
{modeStatus === 'added' ? 'A' : modeStatus === 'deleted' ? 'D' : 'M'}
</span>
)}
</td>
<td className="key-cell">Body Mode</td>
<td className="value-cell">{BODY_TYPE_LABELS[currentMode] || currentMode}</td>
</tr>
</tbody>
</table>
</div>
)}
{bodyTypes.map((type) => {
const content = renderBodyType(type);
if (!content) return null;
const currentVal = currentBody[type];
const otherVal = otherBody[type];
const hasChanges = !isEqual(currentVal, otherVal);
return (
<div key={type} className="diff-section">
<div className="diff-section-header">
<span>{BODY_TYPE_LABELS[type] || type}</span>
{hasChanges && (
<span className={`status-badge ${otherVal === undefined ? (showSide === 'old' ? 'deleted' : 'added') : 'modified'}`}>
{otherVal === undefined ? (showSide === 'old' ? 'D' : 'A') : 'M'}
</span>
)}
</div>
{content}
</div>
);
})}
</>
);
};
export default VisualDiffBody;

View File

@@ -0,0 +1,443 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
display: flex;
flex-direction: column;
.visual-diff-content {
flex: 1;
overflow: auto;
}
.diff-header-row {
display: flex;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.base};
margin-bottom: 1rem;
}
.diff-header-pane {
flex: 1;
padding: 0.5rem 0.75rem;
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
text-transform: uppercase;
&.old {
border-right: 1px solid ${(props) => props.theme.border.border1};
}
}
.diff-sections {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.diff-row {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.base};
overflow: hidden;
}
.diff-row-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: ${(props) => props.theme.sidebar.bg};
cursor: pointer;
user-select: none;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
.collapse-toggle {
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.colors.text.muted};
}
.diff-row-title {
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.text};
}
.diff-row-content {
display: flex;
gap: 1rem;
padding: 0.75rem;
background: ${(props) => props.theme.background.base};
}
.diff-row-pane {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
&.old {
border-left: 2px solid ${(props) => props.theme.colors.text.danger}20;
padding-left: 0.5rem;
}
&.new {
border-left: 2px solid ${(props) => props.theme.colors.text.green}20;
padding-left: 0.5rem;
}
}
.empty-placeholder {
flex: 1;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: ${(props) => props.theme.sidebar.bg};
border: 1px dashed ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.xs};
}
.empty-placeholder::after {
content: 'No content';
}
.diff-section {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
overflow: hidden;
&.added {
border-color: ${(props) => props.theme.colors.text.green};
}
&.deleted {
border-color: ${(props) => props.theme.colors.text.danger};
}
}
.diff-section-header {
padding: 0.375rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
background: ${(props) => props.theme.sidebar.bg};
border-bottom: 1px solid ${(props) => props.theme.border.border1};
display: flex;
align-items: center;
justify-content: space-between;
}
.diff-section-content {
padding: 0.5rem;
}
.url-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
.method {
font-weight: 600;
font-size: ${(props) => props.theme.font.size.xs};
text-transform: uppercase;
padding: 0.125rem 0.375rem;
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.brand}15;
color: ${(props) => props.theme.brand};
}
.url {
flex: 1;
font-family: 'Fira Code', monospace;
font-size: ${(props) => props.theme.font.size.xs};
color: ${(props) => props.theme.text};
word-break: break-all;
&.changed {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent);
padding: 0.125rem 0.25rem;
border-radius: ${(props) => props.theme.border.radius.sm};
}
}
.method.changed {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 30%, transparent);
color: ${(props) => props.theme.colors.text.warning};
}
}
.diff-inline {
padding: 0.125rem 0.25rem;
border-radius: 2px;
&.added {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 25%, transparent);
color: ${(props) => props.theme.colors.text.green};
}
&.deleted {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 25%, transparent);
color: ${(props) => props.theme.colors.text.danger};
text-decoration: line-through;
}
&.modified {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 25%, transparent);
color: ${(props) => props.theme.colors.text.warning};
}
}
.diff-table {
width: 100%;
border-collapse: collapse;
font-size: ${(props) => props.theme.font.size.xs};
th, td {
padding: 0.375rem 0.5rem;
text-align: left;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}
th {
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.sidebar.bg};
}
tr.added {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 10%, transparent);
}
tr.deleted {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 10%, transparent);
}
tr.modified {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 10%, transparent);
}
.checkbox-cell {
width: 24px;
text-align: center;
input[type='checkbox'] {
cursor: default;
width: 12px;
height: 12px;
accent-color: ${(props) => props.theme.colors.accent};
vertical-align: middle;
margin: 0;
}
}
.key-cell {
font-family: 'Fira Code', monospace;
color: ${(props) => props.theme.text};
}
.value-cell {
font-family: 'Fira Code', monospace;
color: ${(props) => props.theme.colors.text.muted};
word-break: break-all;
}
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 0.875rem;
height: 0.875rem;
border-radius: 2px;
font-size: 8px;
font-weight: 600;
&.added {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 13%, transparent);
color: ${(props) => props.theme.colors.text.green};
}
&.deleted {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 13%, transparent);
color: ${(props) => props.theme.colors.text.danger};
}
&.modified {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 13%, transparent);
color: ${(props) => props.theme.colors.text.warning};
}
}
}
.code-diff-content {
max-height: 250px;
overflow: auto;
font-family: 'Fira Code', monospace;
font-size: ${(props) => props.theme.font.size.xs};
line-height: 1.5;
.diff-line {
padding: 0 0.5rem;
white-space: pre-wrap;
word-break: break-word;
&.unchanged {
color: ${(props) => props.theme.text};
}
&.added {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
color: ${(props) => props.theme.colors.text.green};
}
&.deleted {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent);
color: ${(props) => props.theme.colors.text.danger};
text-decoration: line-through;
}
}
}
.example-content {
padding: 0.5rem;
}
.example-block {
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
.example-block-header {
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 0.5rem;
background: ${(props) => props.theme.sidebar.bg};
border-radius: ${(props) => props.theme.border.radius.sm};
margin-bottom: 0.375rem;
}
.example-subsection {
margin-bottom: 0.375rem;
&:last-child {
margin-bottom: 0;
}
}
.example-subsection-title {
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
padding: 0.25rem 0.5rem;
margin-bottom: 0.25rem;
}
.example-description {
font-weight: 400;
color: ${(props) => props.theme.colors.text.muted};
font-style: italic;
}
.status-display {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
font-family: 'Fira Code', monospace;
font-size: ${(props) => props.theme.font.size.xs};
.status-code {
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.sidebar.bg};
&.changed {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent);
color: ${(props) => props.theme.colors.text.warning};
}
}
.status-text {
color: ${(props) => props.theme.colors.text.muted};
&.changed {
color: ${(props) => props.theme.colors.text.warning};
}
}
}
.example-subsection .diff-table {
margin: 0;
}
.example-subsection .code-diff-content {
max-height: 150px;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: ${(props) => props.theme.colors.text.muted};
font-size: ${(props) => props.theme.font.size.sm};
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.tag-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
font-size: ${(props) => props.theme.font.size.xs};
font-family: 'Fira Code', monospace;
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.sidebar.bg};
border: 1px solid ${(props) => props.theme.border.border1};
&.added {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
border-color: ${(props) => props.theme.colors.text.green};
color: ${(props) => props.theme.colors.text.green};
}
&.deleted {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent);
border-color: ${(props) => props.theme.colors.text.danger};
color: ${(props) => props.theme.colors.text.danger};
text-decoration: line-through;
}
&.modified {
background: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 15%, transparent);
border-color: ${(props) => props.theme.colors.text.warning};
color: ${(props) => props.theme.colors.text.warning};
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
import CollapsibleDiffRow from '../CollapsibleDiffRow';
import StyledWrapper from './StyledWrapper';
/**
* VisualDiffContent - Presentational component for rendering visual diffs
*
* This is a reusable component that renders the visual diff UI.
* It can be used by:
* - Git VisualDiffViewer (for git diffs)
* - OpenAPI ChangeSection (for spec diffs)
*
* Props:
* - oldData: The "before" data
* - newData: The "after" data
* - sections: Array of section configs { key, title, Component, hasContent }
* - sectionHasChanges: Function (sectionKey, oldData, newData) => boolean
* - oldLabel: Label for the left/old pane (default: "Before")
* - newLabel: Label for the right/new pane (default: "After")
* - hideUnchanged: Hide sections without changes entirely (default: false)
*/
const VisualDiffContent = ({
oldData,
newData,
sections,
sectionHasChanges,
oldLabel = 'Before',
newLabel = 'After',
hideUnchanged = false
}) => {
const [collapsedSections, setCollapsedSections] = useState({});
const toggleSection = (sectionKey) => {
setCollapsedSections((prev) => ({
...prev,
[sectionKey]: !prev[sectionKey]
}));
};
// Auto-collapse unchanged sections (collapsed but still visible)
useEffect(() => {
if (!sectionHasChanges || (!oldData && !newData)) return;
const initialCollapsed = {};
sections.forEach(({ key }) => {
const hasChanges = sectionHasChanges(key, oldData, newData);
initialCollapsed[key] = !hasChanges;
});
setCollapsedSections(initialCollapsed);
}, [oldData, newData, sections, sectionHasChanges]);
if (!oldData && !newData) {
return (
<StyledWrapper>
<div className="empty-state">
No content to display
</div>
</StyledWrapper>
);
}
return (
<StyledWrapper>
<div className="visual-diff-content">
<div className="diff-header-row">
<div className="diff-header-pane old">{oldLabel}</div>
<div className="diff-header-pane new">{newLabel}</div>
</div>
<div className="diff-sections">
{sections.map(({ key, title, Component, hasContent: checkContent }) => {
const hasOld = oldData && checkContent(oldData);
const hasNew = newData && checkContent(newData);
if (!hasOld && !hasNew) {
return null;
}
// Hide sections without changes entirely when hideUnchanged is enabled
if (hideUnchanged && sectionHasChanges && !sectionHasChanges(key, oldData, newData)) {
return null;
}
return (
<CollapsibleDiffRow
key={key}
title={title}
isCollapsed={collapsedSections[key] || false}
onToggle={() => toggleSection(key)}
hasOldContent={hasOld}
hasNewContent={hasNew}
oldContent={
<Component oldData={oldData} newData={newData} showSide="old" />
}
newContent={
<Component oldData={oldData} newData={newData} showSide="new" />
}
/>
);
})}
</div>
</div>
</StyledWrapper>
);
};
export default VisualDiffContent;

View File

@@ -0,0 +1,74 @@
import React, { useMemo } from 'react';
import get from 'lodash/get';
const VisualDiffHeaders = ({ oldData, newData, showSide }) => {
const oldHeaders = get(oldData, 'request.headers', []);
const newHeaders = get(newData, 'request.headers', []);
const currentHeaders = showSide === 'old' ? oldHeaders : newHeaders;
const otherHeaders = showSide === 'old' ? newHeaders : oldHeaders;
const headersWithStatus = useMemo(() => {
const otherHeaderMap = new Map();
otherHeaders.forEach((h) => {
otherHeaderMap.set(h.name, h);
});
return currentHeaders.map((header) => {
const otherHeader = otherHeaderMap.get(header.name);
let status = 'unchanged';
if (!otherHeader) {
status = showSide === 'old' ? 'deleted' : 'added';
} else if (header.value !== otherHeader.value || header.enabled !== otherHeader.enabled) {
status = 'modified';
}
return { ...header, status };
});
}, [currentHeaders, otherHeaders, showSide]);
if (headersWithStatus.length === 0) {
return null;
}
return (
<div className="diff-section">
<table className="diff-table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th className="checkbox-cell"></th>
<th style={{ width: '40%' }}>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{headersWithStatus.map((header, index) => (
<tr key={`${header.name}-${index}`} className={header.status}>
<td>
{header.status !== 'unchanged' && (
<span className={`status-badge ${header.status}`}>
{header.status === 'added' ? 'A' : header.status === 'deleted' ? 'D' : 'M'}
</span>
)}
</td>
<td className="checkbox-cell">
<input
type="checkbox"
checked={header.enabled !== false}
readOnly
disabled
/>
</td>
<td className="key-cell">{header.name}</td>
<td className="value-cell">{header.value}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
export default VisualDiffHeaders;

View File

@@ -0,0 +1,89 @@
import React, { useMemo } from 'react';
import get from 'lodash/get';
const VisualDiffParams = ({ oldData, newData, showSide }) => {
const oldParams = get(oldData, 'request.params', []);
const newParams = get(newData, 'request.params', []);
const currentParams = showSide === 'old' ? oldParams : newParams;
const otherParams = showSide === 'old' ? newParams : oldParams;
const paramsWithStatus = useMemo(() => {
const otherParamMap = new Map();
otherParams.forEach((p) => {
otherParamMap.set(p.name, p);
});
return currentParams.map((param) => {
const otherParam = otherParamMap.get(param.name);
let status = 'unchanged';
if (!otherParam) {
status = showSide === 'old' ? 'deleted' : 'added';
} else if (param.value !== otherParam.value || param.enabled !== otherParam.enabled) {
status = 'modified';
}
return { ...param, status };
});
}, [currentParams, otherParams, showSide]);
const queryParams = paramsWithStatus.filter((p) => p.type === 'query');
const pathParams = paramsWithStatus.filter((p) => p.type === 'path');
if (queryParams.length === 0 && pathParams.length === 0) {
return null;
}
const renderTable = (params, title) => {
if (params.length === 0) return null;
return (
<div className="diff-section">
<div className="diff-section-header">{title}</div>
<table className="diff-table">
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th className="checkbox-cell"></th>
<th style={{ width: '40%' }}>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{params.map((param, index) => (
<tr key={`${param.name}-${index}`} className={param.status}>
<td>
{param.status !== 'unchanged' && (
<span className={`status-badge ${param.status}`}>
{param.status === 'added' ? 'A' : param.status === 'deleted' ? 'D' : 'M'}
</span>
)}
</td>
<td className="checkbox-cell">
<input
type="checkbox"
checked={param.enabled !== false}
readOnly
disabled
/>
</td>
<td className="key-cell">{param.name}</td>
<td className="value-cell">{param.value}</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
return (
<>
{renderTable(queryParams, 'Query Parameters')}
{renderTable(pathParams, 'Path Parameters')}
</>
);
};
export default VisualDiffParams;

View File

@@ -0,0 +1,55 @@
import React, { useMemo } from 'react';
import { computeWordDiffForOld, computeWordDiffForNew } from './utils/diffUtils';
import { getMethod, getUrl } from './utils/bruUtils';
const VisualDiffUrlBar = ({ oldData, newData, showSide }) => {
const oldMethod = getMethod(oldData);
const newMethod = getMethod(newData);
const oldUrl = getUrl(oldData);
const newUrl = getUrl(newData);
const currentMethod = showSide === 'old' ? oldMethod : newMethod;
const urlDiffSegments = useMemo(() => {
if (showSide === 'old') {
return computeWordDiffForOld(oldUrl, newUrl);
} else {
return computeWordDiffForNew(oldUrl, newUrl);
}
}, [oldUrl, newUrl, showSide]);
const methodChanged = oldMethod !== newMethod;
const methodStatus = useMemo(() => {
if (!methodChanged) return 'unchanged';
if (showSide === 'old') return 'deleted';
return 'added';
}, [methodChanged, showSide]);
const renderDiffSegments = (segments) => {
return segments.map((segment, index) => {
if (segment.status === 'unchanged') {
return <span key={index}>{segment.text}</span>;
}
return (
<span key={index} className={`diff-inline ${segment.status}`}>
{segment.text}
</span>
);
});
};
return (
<div className="diff-section">
<div className="url-bar">
<span className={`method ${methodStatus !== 'unchanged' ? `diff-inline ${methodStatus}` : ''}`}>
{currentMethod?.toUpperCase() || 'GET'}
</span>
<span className="url">
{renderDiffSegments(urlDiffSegments)}
</span>
</div>
</div>
);
};
export default VisualDiffUrlBar;

View File

@@ -0,0 +1,53 @@
import get from 'lodash/get';
export const DIFF_STATUS = Object.freeze({
ADDED: 'added',
DELETED: 'deleted',
MODIFIED: 'modified',
UNCHANGED: 'unchanged'
});
export const getBodyContent = (body) => {
if (!body) return '';
if (body.json) return body.json;
if (body.text) return body.text;
if (body.xml) return body.xml;
if (body.sparql) return body.sparql;
if (body.graphql?.query) return body.graphql.query;
if (body.content) return body.content;
return '';
};
export const getBodyMode = (body) => {
if (!body) return 'none';
if (body.json !== undefined) return 'json';
if (body.text !== undefined) return 'text';
if (body.xml !== undefined) return 'xml';
if (body.sparql !== undefined) return 'sparql';
if (body.graphql) return 'graphql';
if (body.formUrlEncoded) return 'formUrlEncoded';
if (body.multipartForm) return 'multipartForm';
if (body.file) return 'file';
if (body.grpc) return 'grpc';
if (body.ws) return 'ws';
if (body.mode === 'none') return 'none';
return 'none';
};
export const getMethod = (data) => {
return get(data, 'request.method', 'GET');
};
export const getUrl = (data) => {
return get(data, 'request.url', '');
};
export const computeItemDiffStatus = (currentItem, otherItem, showSide) => {
if (!otherItem) {
return showSide === 'old' ? DIFF_STATUS.DELETED : DIFF_STATUS.ADDED;
}
if (currentItem.value !== otherItem.value || currentItem.enabled !== otherItem.enabled) {
return DIFF_STATUS.MODIFIED;
}
return DIFF_STATUS.UNCHANGED;
};

View File

@@ -0,0 +1,194 @@
const { describe, it, expect } = require('@jest/globals');
import {
getBodyContent,
getBodyMode,
getMethod,
getUrl,
computeItemDiffStatus
} from './bruUtils';
describe('bruUtils', () => {
describe('getBodyContent', () => {
it('should return empty string for null or undefined body', () => {
expect(getBodyContent(null)).toBe('');
expect(getBodyContent(undefined)).toBe('');
});
it('should return empty string for empty body', () => {
expect(getBodyContent({})).toBe('');
});
it('should return json content', () => {
expect(getBodyContent({ json: '{"key": "value"}' })).toBe('{"key": "value"}');
});
it('should return text content', () => {
expect(getBodyContent({ text: 'plain text content' })).toBe('plain text content');
});
it('should return xml content', () => {
expect(getBodyContent({ xml: '<root><item>value</item></root>' })).toBe('<root><item>value</item></root>');
});
it('should return sparql content', () => {
expect(getBodyContent({ sparql: 'SELECT * WHERE { ?s ?p ?o }' })).toBe('SELECT * WHERE { ?s ?p ?o }');
});
it('should return graphql query content', () => {
expect(getBodyContent({ graphql: { query: 'query { users { id } }' } })).toBe('query { users { id } }');
});
it('should return generic content', () => {
expect(getBodyContent({ content: 'generic content' })).toBe('generic content');
});
it('should return empty string for graphql without query', () => {
expect(getBodyContent({ graphql: {} })).toBe('');
expect(getBodyContent({ graphql: { variables: '{}' } })).toBe('');
});
it('should prioritize json over other types', () => {
expect(getBodyContent({ json: '{"a":1}', text: 'text' })).toBe('{"a":1}');
});
});
describe('getBodyMode', () => {
it('should return none for null or undefined body', () => {
expect(getBodyMode(null)).toBe('none');
expect(getBodyMode(undefined)).toBe('none');
});
it('should return none for empty body', () => {
expect(getBodyMode({})).toBe('none');
});
it('should return json mode', () => {
expect(getBodyMode({ json: '{}' })).toBe('json');
expect(getBodyMode({ json: '' })).toBe('json');
});
it('should return text mode', () => {
expect(getBodyMode({ text: 'content' })).toBe('text');
expect(getBodyMode({ text: '' })).toBe('text');
});
it('should return xml mode', () => {
expect(getBodyMode({ xml: '<root/>' })).toBe('xml');
});
it('should return sparql mode', () => {
expect(getBodyMode({ sparql: 'SELECT *' })).toBe('sparql');
});
it('should return graphql mode', () => {
expect(getBodyMode({ graphql: { query: '' } })).toBe('graphql');
});
it('should return formUrlEncoded mode', () => {
expect(getBodyMode({ formUrlEncoded: [] })).toBe('formUrlEncoded');
expect(getBodyMode({ formUrlEncoded: [{ name: 'key', value: 'val' }] })).toBe('formUrlEncoded');
});
it('should return multipartForm mode', () => {
expect(getBodyMode({ multipartForm: [] })).toBe('multipartForm');
});
it('should return file mode', () => {
expect(getBodyMode({ file: [] })).toBe('file');
});
it('should return grpc mode', () => {
expect(getBodyMode({ grpc: [] })).toBe('grpc');
});
it('should return ws mode', () => {
expect(getBodyMode({ ws: [] })).toBe('ws');
});
it('should return none for explicit none mode', () => {
expect(getBodyMode({ mode: 'none' })).toBe('none');
});
it('should prioritize json over other modes', () => {
expect(getBodyMode({ json: '{}', text: 'text' })).toBe('json');
});
});
describe('getMethod', () => {
it('should return GET as default', () => {
expect(getMethod(null)).toBe('GET');
expect(getMethod(undefined)).toBe('GET');
expect(getMethod({})).toBe('GET');
});
it('should return request method', () => {
expect(getMethod({ request: { method: 'POST' } })).toBe('POST');
expect(getMethod({ request: { method: 'PUT' } })).toBe('PUT');
expect(getMethod({ request: { method: 'DELETE' } })).toBe('DELETE');
});
it('should return GET when request exists but method is missing', () => {
expect(getMethod({ request: {} })).toBe('GET');
});
});
describe('getUrl', () => {
it('should return empty string as default', () => {
expect(getUrl(null)).toBe('');
expect(getUrl(undefined)).toBe('');
expect(getUrl({})).toBe('');
});
it('should return request url', () => {
expect(getUrl({ request: { url: 'https://api.example.com/users' } })).toBe('https://api.example.com/users');
});
it('should return empty string when request exists but url is missing', () => {
expect(getUrl({ request: {} })).toBe('');
});
it('should return url with different protocols', () => {
expect(getUrl({ request: { url: 'http://localhost:3000' } })).toBe('http://localhost:3000');
expect(getUrl({ request: { url: 'ws://localhost:8080' } })).toBe('ws://localhost:8080');
expect(getUrl({ request: { url: 'grpc://localhost:50051' } })).toBe('grpc://localhost:50051');
});
});
describe('computeItemDiffStatus', () => {
it('should return deleted when other item is missing and showing old side', () => {
expect(computeItemDiffStatus({ name: 'key', value: 'val' }, null, 'old')).toBe('deleted');
expect(computeItemDiffStatus({ name: 'key', value: 'val' }, undefined, 'old')).toBe('deleted');
});
it('should return added when other item is missing and showing new side', () => {
expect(computeItemDiffStatus({ name: 'key', value: 'val' }, null, 'new')).toBe('added');
expect(computeItemDiffStatus({ name: 'key', value: 'val' }, undefined, 'new')).toBe('added');
});
it('should return unchanged when items are equal', () => {
const item = { name: 'key', value: 'val', enabled: true };
const otherItem = { name: 'key', value: 'val', enabled: true };
expect(computeItemDiffStatus(item, otherItem, 'old')).toBe('unchanged');
expect(computeItemDiffStatus(item, otherItem, 'new')).toBe('unchanged');
});
it('should return modified when values differ', () => {
const item = { name: 'key', value: 'val1', enabled: true };
const otherItem = { name: 'key', value: 'val2', enabled: true };
expect(computeItemDiffStatus(item, otherItem, 'old')).toBe('modified');
});
it('should return modified when enabled status differs', () => {
const item = { name: 'key', value: 'val', enabled: true };
const otherItem = { name: 'key', value: 'val', enabled: false };
expect(computeItemDiffStatus(item, otherItem, 'old')).toBe('modified');
});
it('should handle undefined enabled as different from explicit false', () => {
const item = { name: 'key', value: 'val' };
const otherItem = { name: 'key', value: 'val', enabled: false };
expect(computeItemDiffStatus(item, otherItem, 'old')).toBe('modified');
});
});
});

View File

@@ -0,0 +1,202 @@
// Matches word-boundary separators: whitespace, slashes, query/path delimiters (?&=), dots, hyphens, underscores, colons, @
const WORD_SEPARATOR = /[\s\/\?\&\=\.\-\_\:\@]/;
const splitWithSeparators = (str) => {
const result = [];
let current = '';
for (const char of str) {
if (WORD_SEPARATOR.test(char)) {
if (current) {
result.push(current);
current = '';
}
result.push(char);
} else {
current += char;
}
}
if (current) {
result.push(current);
}
return result;
};
export const computeWordDiffForOld = (oldStr, newStr) => {
if (oldStr === newStr) {
return [{ text: oldStr, status: 'unchanged' }];
}
if (!oldStr) {
return [];
}
if (!newStr) {
return [{ text: oldStr, status: 'deleted' }];
}
const oldWords = splitWithSeparators(oldStr);
const newWords = splitWithSeparators(newStr);
const lcs = computeLCS(oldWords, newWords);
const segments = [];
let oldIdx = 0;
let lcsIdx = 0;
while (oldIdx < oldWords.length) {
if (lcsIdx < lcs.length && oldIdx === lcs[lcsIdx].oldIndex) {
segments.push({ text: oldWords[oldIdx], status: 'unchanged' });
lcsIdx++;
} else {
segments.push({ text: oldWords[oldIdx], status: 'deleted' });
}
oldIdx++;
}
return mergeSegments(segments);
};
export const computeWordDiffForNew = (oldStr, newStr) => {
if (oldStr === newStr) {
return [{ text: newStr, status: 'unchanged' }];
}
if (!newStr) {
return [];
}
if (!oldStr) {
return [{ text: newStr, status: 'added' }];
}
const oldWords = splitWithSeparators(oldStr);
const newWords = splitWithSeparators(newStr);
const lcs = computeLCS(oldWords, newWords);
const segments = [];
let newIdx = 0;
let lcsIdx = 0;
while (newIdx < newWords.length) {
if (lcsIdx < lcs.length && newIdx === lcs[lcsIdx].newIndex) {
segments.push({ text: newWords[newIdx], status: 'unchanged' });
lcsIdx++;
} else {
segments.push({ text: newWords[newIdx], status: 'added' });
}
newIdx++;
}
return mergeSegments(segments);
};
const mergeSegments = (segments) => {
const merged = [];
for (const segment of segments) {
if (merged.length > 0 && merged[merged.length - 1].status === segment.status) {
merged[merged.length - 1].text += segment.text;
} else {
merged.push({ ...segment });
}
}
return merged;
};
const computeLCS = (arr1, arr2) => {
const m = arr1.length;
const n = arr2.length;
const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (arr1[i - 1] === arr2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
const lcs = [];
let i = m, j = n;
while (i > 0 && j > 0) {
if (arr1[i - 1] === arr2[j - 1]) {
lcs.unshift({ value: arr1[i - 1], oldIndex: i - 1, newIndex: j - 1 });
i--;
j--;
} else if (dp[i - 1][j] > dp[i][j - 1]) {
i--;
} else {
j--;
}
}
return lcs;
};
export const computeLineDiffForOld = (oldStr, newStr) => {
if (oldStr === newStr) {
return (oldStr || '').split('\n').map((line) => ({ text: line, status: 'unchanged' }));
}
if (!oldStr) {
return [];
}
if (!newStr) {
return oldStr.split('\n').map((line) => ({ text: line, status: 'deleted' }));
}
const oldLines = oldStr.split('\n');
const newLines = newStr.split('\n');
const lcs = computeLCS(oldLines, newLines);
const segments = [];
let oldIdx = 0;
let lcsIdx = 0;
while (oldIdx < oldLines.length) {
if (lcsIdx < lcs.length && oldIdx === lcs[lcsIdx].oldIndex) {
segments.push({ text: oldLines[oldIdx], status: 'unchanged' });
lcsIdx++;
} else {
segments.push({ text: oldLines[oldIdx], status: 'deleted' });
}
oldIdx++;
}
return segments;
};
export const computeLineDiffForNew = (oldStr, newStr) => {
if (oldStr === newStr) {
return (newStr || '').split('\n').map((line) => ({ text: line, status: 'unchanged' }));
}
if (!newStr) {
return [];
}
if (!oldStr) {
return newStr.split('\n').map((line) => ({ text: line, status: 'added' }));
}
const oldLines = oldStr.split('\n');
const newLines = newStr.split('\n');
const lcs = computeLCS(oldLines, newLines);
const segments = [];
let newIdx = 0;
let lcsIdx = 0;
while (newIdx < newLines.length) {
if (lcsIdx < lcs.length && newIdx === lcs[lcsIdx].newIndex) {
segments.push({ text: newLines[newIdx], status: 'unchanged' });
lcsIdx++;
} else {
segments.push({ text: newLines[newIdx], status: 'added' });
}
newIdx++;
}
return segments;
};

View File

@@ -0,0 +1,198 @@
const { describe, it, expect } = require('@jest/globals');
import {
computeWordDiffForOld,
computeWordDiffForNew,
computeLineDiffForOld,
computeLineDiffForNew
} from './diffUtils';
describe('diffUtils', () => {
describe('computeWordDiffForOld', () => {
it('should return unchanged for identical strings', () => {
expect(computeWordDiffForOld('hello world', 'hello world')).toEqual([
{ text: 'hello world', status: 'unchanged' }
]);
});
it('should return empty array for empty old string', () => {
expect(computeWordDiffForOld('', 'new text')).toEqual([]);
expect(computeWordDiffForOld(null, 'new text')).toEqual([]);
expect(computeWordDiffForOld(undefined, 'new text')).toEqual([]);
});
it('should return deleted for entire old string when new is empty', () => {
expect(computeWordDiffForOld('old text', '')).toEqual([
{ text: 'old text', status: 'deleted' }
]);
expect(computeWordDiffForOld('old text', null)).toEqual([
{ text: 'old text', status: 'deleted' }
]);
});
it('should detect deleted words', () => {
const result = computeWordDiffForOld('hello world', 'hello');
expect(result).toContainEqual({ text: 'hello', status: 'unchanged' });
expect(result.some((s) => s.status === 'deleted' && s.text.includes('world'))).toBe(true);
});
it('should handle URL paths', () => {
const result = computeWordDiffForOld(
'https://api.example.com/users/123',
'https://api.example.com/users/456'
);
expect(result.some((s) => s.status === 'unchanged')).toBe(true);
expect(result.some((s) => s.status === 'deleted')).toBe(true);
});
it('should preserve separators', () => {
const result = computeWordDiffForOld('a/b/c', 'a/b/c');
expect(result).toEqual([{ text: 'a/b/c', status: 'unchanged' }]);
});
});
describe('computeWordDiffForNew', () => {
it('should return unchanged for identical strings', () => {
expect(computeWordDiffForNew('hello world', 'hello world')).toEqual([
{ text: 'hello world', status: 'unchanged' }
]);
});
it('should return empty array for empty new string', () => {
expect(computeWordDiffForNew('old text', '')).toEqual([]);
expect(computeWordDiffForNew('old text', null)).toEqual([]);
expect(computeWordDiffForNew('old text', undefined)).toEqual([]);
});
it('should return added for entire new string when old is empty', () => {
expect(computeWordDiffForNew('', 'new text')).toEqual([
{ text: 'new text', status: 'added' }
]);
expect(computeWordDiffForNew(null, 'new text')).toEqual([
{ text: 'new text', status: 'added' }
]);
});
it('should detect added words', () => {
const result = computeWordDiffForNew('hello', 'hello world');
expect(result).toContainEqual({ text: 'hello', status: 'unchanged' });
expect(result.some((s) => s.status === 'added' && s.text.includes('world'))).toBe(true);
});
it('should handle URL paths', () => {
const result = computeWordDiffForNew(
'https://api.example.com/users/123',
'https://api.example.com/users/456'
);
expect(result.some((s) => s.status === 'unchanged')).toBe(true);
expect(result.some((s) => s.status === 'added')).toBe(true);
});
});
describe('computeLineDiffForOld', () => {
it('should return unchanged for identical multiline strings', () => {
const text = 'line1\nline2\nline3';
expect(computeLineDiffForOld(text, text)).toEqual([
{ text: 'line1', status: 'unchanged' },
{ text: 'line2', status: 'unchanged' },
{ text: 'line3', status: 'unchanged' }
]);
});
it('should return empty array for empty old string', () => {
expect(computeLineDiffForOld('', 'new\ntext')).toEqual([]);
expect(computeLineDiffForOld(null, 'new\ntext')).toEqual([]);
});
it('should return deleted for all lines when new is empty', () => {
expect(computeLineDiffForOld('line1\nline2', '')).toEqual([
{ text: 'line1', status: 'deleted' },
{ text: 'line2', status: 'deleted' }
]);
});
it('should detect deleted lines', () => {
const result = computeLineDiffForOld('line1\nline2\nline3', 'line1\nline3');
expect(result).toContainEqual({ text: 'line1', status: 'unchanged' });
expect(result).toContainEqual({ text: 'line2', status: 'deleted' });
expect(result).toContainEqual({ text: 'line3', status: 'unchanged' });
});
it('should handle single line strings', () => {
expect(computeLineDiffForOld('single line', 'single line')).toEqual([
{ text: 'single line', status: 'unchanged' }
]);
});
it('should handle code blocks', () => {
const oldCode = 'function foo() {\n return 1;\n}';
const newCode = 'function foo() {\n return 2;\n}';
const result = computeLineDiffForOld(oldCode, newCode);
expect(result).toContainEqual({ text: 'function foo() {', status: 'unchanged' });
expect(result).toContainEqual({ text: ' return 1;', status: 'deleted' });
expect(result).toContainEqual({ text: '}', status: 'unchanged' });
});
});
describe('computeLineDiffForNew', () => {
it('should return unchanged for identical multiline strings', () => {
const text = 'line1\nline2\nline3';
expect(computeLineDiffForNew(text, text)).toEqual([
{ text: 'line1', status: 'unchanged' },
{ text: 'line2', status: 'unchanged' },
{ text: 'line3', status: 'unchanged' }
]);
});
it('should return empty array for empty new string', () => {
expect(computeLineDiffForNew('old\ntext', '')).toEqual([]);
expect(computeLineDiffForNew('old\ntext', null)).toEqual([]);
});
it('should return added for all lines when old is empty', () => {
expect(computeLineDiffForNew('', 'line1\nline2')).toEqual([
{ text: 'line1', status: 'added' },
{ text: 'line2', status: 'added' }
]);
});
it('should detect added lines', () => {
const result = computeLineDiffForNew('line1\nline3', 'line1\nline2\nline3');
expect(result).toContainEqual({ text: 'line1', status: 'unchanged' });
expect(result).toContainEqual({ text: 'line2', status: 'added' });
expect(result).toContainEqual({ text: 'line3', status: 'unchanged' });
});
it('should handle code blocks', () => {
const oldCode = 'function foo() {\n return 1;\n}';
const newCode = 'function foo() {\n return 2;\n}';
const result = computeLineDiffForNew(oldCode, newCode);
expect(result).toContainEqual({ text: 'function foo() {', status: 'unchanged' });
expect(result).toContainEqual({ text: ' return 2;', status: 'added' });
expect(result).toContainEqual({ text: '}', status: 'unchanged' });
});
});
describe('edge cases', () => {
it('should handle empty strings', () => {
expect(computeWordDiffForOld('', '')).toEqual([{ text: '', status: 'unchanged' }]);
expect(computeWordDiffForNew('', '')).toEqual([{ text: '', status: 'unchanged' }]);
});
it('should handle strings with only whitespace', () => {
const result = computeWordDiffForOld(' ', ' ');
expect(result).toEqual([{ text: ' ', status: 'unchanged' }]);
});
it('should handle special characters in URLs', () => {
const url = 'https://api.example.com/users?id=123&name=test';
expect(computeWordDiffForOld(url, url)).toEqual([{ text: url, status: 'unchanged' }]);
});
it('should handle JSON-like content', () => {
const json = '{"key": "value", "number": 123}';
const result = computeLineDiffForOld(json, json);
expect(result).toEqual([{ text: json, status: 'unchanged' }]);
});
});
});

View File

@@ -3,6 +3,8 @@ import styled from 'styled-components';
const Wrapper = styled.div`
font-weight: 400;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.text};
white-space: normal;
background-color: ${(props) => props.theme.infoTip.bg};
border: 1px solid ${(props) => props.theme.infoTip.border};
box-shadow: ${(props) => props.theme.infoTip.boxShadow};

View File

@@ -4,62 +4,84 @@
* We should allow icon and placement props to be passed in
*/
import React, { useState } from 'react';
import HelpIcon from 'components/Icons/Help';
import React, { useState, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import QuestionCircle from 'components/Icons/QuestionCircle';
import InfoCircle from 'components/Icons/InfoCircle';
import StyledWrapper from './StyledWrapper';
const getPlacementStyles = (placement) => {
const GAP = 8;
const getPortalPosition = (rect, placement, width) => {
switch (placement) {
case 'top':
return {
bottom: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)'
top: rect.top - GAP,
left: rect.left + rect.width / 2 - width / 2,
transform: 'translateY(-100%)'
};
case 'bottom':
return {
top: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)'
top: rect.bottom + GAP,
left: rect.left + rect.width / 2 - width / 2
};
case 'left':
return {
top: '50%',
right: 'calc(100% + 8px)',
top: rect.top + rect.height / 2,
left: rect.left - GAP - width,
transform: 'translateY(-50%)'
};
case 'right':
default:
return {
top: '50%',
left: 'calc(100% + 8px)',
top: rect.top + rect.height / 2,
left: rect.right + GAP,
transform: 'translateY(-50%)'
};
}
};
const Help = ({ children, width = 200, placement = 'right' }) => {
const iconMap = {
question: QuestionCircle,
info: InfoCircle
};
const Help = ({ children, width = 200, placement = 'right', icon = 'question', iconComponent: IconComponent, size = 14 }) => {
const [showTooltip, setShowTooltip] = useState(false);
const [position, setPosition] = useState(null);
const iconRef = useRef(null);
const ResolvedIcon = IconComponent || iconMap[icon] || QuestionCircle;
const handleMouseEnter = useCallback(() => {
if (iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
setPosition(getPortalPosition(rect, placement, width));
}
setShowTooltip(true);
}, [placement, width]);
return (
<div className="flex items-center relative">
<div className="flex items-center">
<span
ref={iconRef}
className="flex items-center"
onMouseEnter={() => setShowTooltip(true)}
onMouseEnter={handleMouseEnter}
onMouseLeave={() => setShowTooltip(false)}
>
<HelpIcon size={14} />
<ResolvedIcon size={size} />
</span>
{showTooltip && (
{showTooltip && position && createPortal(
<StyledWrapper
className="absolute z-50 rounded-md p-3"
className="z-50 rounded-md p-3"
style={{
...getPlacementStyles(placement),
position: 'fixed',
...position,
width: `${width}px`
}}
>
{children}
</StyledWrapper>
</StyledWrapper>,
document.body
)}
</div>
);

View File

@@ -0,0 +1,20 @@
import React from 'react';
const InfoCircle = ({ size = 14 }) => {
return (
<svg
tabIndex="-1"
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill="currentColor"
className="inline-block ml-2 cursor-pointer"
viewBox="0 0 16 16"
>
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z" />
<path d="m8.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533L8.93 6.588zM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0z" />
</svg>
);
};
export default InfoCircle;

View File

@@ -0,0 +1,13 @@
import React from 'react';
const OpenAPISyncIcon = ({ size = 16, color = 'currentColor', ...props }) => {
return (
<svg width={size} height={size} viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M39.1499 0.0455742C36.8449 0.458965 35.0171 2.12048 34.4267 4.37029C34.2407 5.06987 34.2245 6.31799 34.3944 7.02553C34.4591 7.30377 34.4914 7.56612 34.4672 7.61382C34.4348 7.66152 34.4429 7.67742 34.4914 7.65357C34.5319 7.62972 34.6208 7.76486 34.6936 7.94771L34.823 8.28955L32.9305 10.1498C31.8872 11.1753 30.9976 12.0021 30.9491 11.9862C30.9006 11.9783 30.8844 12.0021 30.9087 12.0419C30.9329 12.0816 29.6227 13.4172 27.9971 15.0072C26.3716 16.5892 24.6085 18.3222 24.0828 18.8469L23.1284 19.793L22.3278 19.4193C21.1794 18.8787 20.4515 18.7118 19.2707 18.7118C17.726 18.7038 16.715 18.99 15.4776 19.793C14.2079 20.6118 13.3506 21.5499 12.7198 22.8059C12.1779 23.8792 12.0081 24.6503 12 25.962C12 27.0671 12.0809 27.5202 12.4448 28.506C13.205 30.5411 15.138 32.1788 17.5319 32.8068C18.5509 33.0771 20.1765 33.0612 21.2683 32.775C25.5224 31.6621 27.803 27.393 26.2583 23.426C26.1208 23.0683 26.0157 22.7582 26.0319 22.7423C26.048 22.7264 27.0994 21.6771 28.3692 20.421C29.6389 19.1649 31.1108 17.6863 31.6446 17.1377C34.2488 14.4666 37.8397 10.8573 37.8882 10.8573C37.9044 10.8573 38.058 10.9289 38.2198 11.0084C39.1984 11.5013 40.8402 11.5887 41.9967 11.223C42.8782 10.9448 43.5737 10.5235 44.245 9.87157C45.4581 8.67909 45.9919 7.44687 46 5.80126C46 4.37824 45.539 3.16191 44.5442 1.96148C44.2774 1.63554 44.059 1.39705 44.059 1.42885C44.059 1.46065 43.9134 1.36525 43.7274 1.2142C43.2664 0.824657 42.4253 0.403316 41.7136 0.212521C41.01 0.0217247 39.7645 -0.0577736 39.1499 0.0455742Z" fill={color} />
<circle cx="20" cy="26" r="18.5" stroke={color} strokeWidth="3" />
<path d="M26 15.4988C22 13.4999 17.793 13.752 15.2009 14.6172C12.6088 15.4823 10.3896 17.2063 8.91029 19.504C7.431 21.8016 6.78025 24.5354 7.06564 27.2531C7.35103 29.9709 8.55548 32.5098 10.4798 34.4501C12.4041 36.3904 14.933 37.6157 17.6483 37.9235C20.3636 38.2314 23.1027 37.6032 25.4125 36.1429C27.7223 34.6826 29.6135 32.5849 30.5 30C31.3865 27.4151 31.5 25 31 20L28 22.5C28.5 24.5 28.0118 26.9632 27.359 28.8667C26.7061 30.7702 25.4231 32.3939 23.7222 33.4693C22.0212 34.5446 20.0042 35.0072 18.0046 34.7805C16.0051 34.5539 14.1427 33.6515 12.7257 32.2227C11.3086 30.7939 10.4216 28.9242 10.2115 26.9228C10.0013 24.9214 10.4805 22.9083 11.5699 21.2163C12.6592 19.5242 14.2935 18.2547 16.2023 17.6176C18.1112 16.9805 20 16.9999 23.5 17.9999L26 15.4988Z" fill={color} />
</svg>
);
};
export default OpenAPISyncIcon;

View File

@@ -1,6 +1,6 @@
import React from 'react';
const HelpIcon = ({ size = 14 }) => {
const QuestionCircle = ({ size = 14 }) => {
return (
<svg
tabIndex="-1"
@@ -17,4 +17,4 @@ const HelpIcon = ({ size = 14 }) => {
);
};
export default HelpIcon;
export default QuestionCircle;

View File

@@ -25,7 +25,7 @@ const RenameWorkspace = ({ onClose, workspace }) => {
.test('unique-name', 'A workspace with this name already exists', function (value) {
if (!value) return true;
return !workspaces.some((w) =>
w.uid !== workspace.uid && w.name.toLowerCase() === value.toLowerCase()
w.uid !== workspace.uid && w.name && w.name.toLowerCase() === value.toLowerCase()
);
})
}),

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ import * as MarkdownItReplaceLink from 'markdown-it-replace-link';
import StyledWrapper from './StyledWrapper';
import React from 'react';
import { isValidUrl } from 'utils/url/index';
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = {
@@ -33,14 +35,14 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
};
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
const htmlFromMarkdown = md.render(content || '');
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
return (
<StyledWrapper>
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: htmlFromMarkdown }}
dangerouslySetInnerHTML={{ __html: cleanHTML }}
onClick={handleOnClick}
onDoubleClick={handleOnDoubleClick}
/>

View File

@@ -185,6 +185,45 @@ const Wrapper = styled.div`
input[type='checkbox'] {
cursor: pointer;
accent-color: ${(props) => props.theme.primary.solid};
}
.checkbox {
appearance: none;
-webkit-appearance: none;
width: 1rem;
height: 1rem;
border: 1px solid ${(props) => props.theme.border.border2};
border-radius: 3px;
background: transparent;
position: relative;
flex-shrink: 0;
&:hover {
border-color: ${(props) => props.theme.primary.solid};
}
&:focus-visible {
outline: 2px solid ${(props) => props.theme.textLink};
outline-offset: 2px;
}
&:checked {
background: ${(props) => props.theme.button2.color.primary.bg};
border-color: ${(props) => props.theme.button2.color.primary.border};
&::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid ${(props) => props.theme.button2.color.primary.text};
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
}
`;

View File

@@ -164,6 +164,10 @@ class MultiLineEditor extends Component {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
// Re-apply masking after setValue() since it destroys all CodeMirror marks
if (this.maskedEditor && this.maskedEditor.isEnabled()) {
this.maskedEditor.update();
}
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {

View File

@@ -0,0 +1,105 @@
import React, { useState, useEffect, useCallback } from 'react';
import { IconLoader2, IconCloud } from '@tabler/icons';
import fastJsonFormat from 'fast-json-format';
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
/**
* Pretty-print JSON content for readable display. YAML content is returned as-is.
*/
const prettyPrintSpec = (content) => {
if (!content) return content;
if (content.trimStart()[0] !== '{') return content;
try {
return fastJsonFormat(content);
} catch {
return content;
}
};
const OpenAPISpecTab = ({ collection }) => {
const [specContent, setSpecContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [isRemote, setIsRemote] = useState(false);
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const sourceUrl = openApiSyncConfig?.sourceUrl;
const loadSpec = useCallback(async () => {
setIsLoading(true);
setError(null);
setIsRemote(false);
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:read-openapi-spec', {
collectionPath: collection.pathname
});
if (result.error) {
// Local file not found — fall back to fetching from remote URL
if (sourceUrl) {
const fetchResult = await ipcRenderer.invoke('renderer:fetch-openapi-spec', {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl,
environmentContext: {
activeEnvironmentUid: collection.activeEnvironmentUid,
environments: collection.environments,
runtimeVariables: collection.runtimeVariables,
globalEnvironmentVariables: collection.globalEnvironmentVariables
}
});
if (fetchResult.content) {
setSpecContent(prettyPrintSpec(fetchResult.content));
setIsRemote(true);
return;
}
}
setError(result.error);
} else {
setSpecContent(prettyPrintSpec(result.content));
}
} catch (err) {
setError(err.message || 'Failed to read spec file');
} finally {
setIsLoading(false);
}
}, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
useEffect(() => {
if (collection?.pathname) {
loadSpec();
}
}, [loadSpec]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-full gap-2 opacity-50">
<IconLoader2 size={20} className="animate-spin" />
<span>Loading spec...</span>
</div>
);
}
if (error || !specContent) {
return (
<div className="flex items-center justify-center h-full opacity-50">
<span>{error || 'No spec file found. Sync your collection first.'}</span>
</div>
);
}
return (
<StyledWrapper className="flex flex-col flex-grow relative">
{isRemote && (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs opacity-60" style={{ borderBottom: '1px solid var(--color-border)' }}>
<IconCloud size={14} />
<span>Showing spec file from {sourceUrl}.</span>
</div>
)}
<SpecViewer content={specContent} readOnly />
</StyledWrapper>
);
};
export default OpenAPISpecTab;

View File

@@ -0,0 +1,257 @@
import { useMemo } from 'react';
import {
IconCheck,
IconPlus,
IconTrash,
IconArrowBackUp,
IconExternalLink,
IconAlertTriangle,
IconInfoCircle,
IconLoader2
} from '@tabler/icons';
import moment from 'moment';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import Modal from 'components/Modal';
import EndpointChangeSection from '../EndpointChangeSection';
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
import useEndpointActions from '../hooks/useEndpointActions';
const CollectionStatusSection = ({
collection,
collectionDrift,
reloadDrift,
specDrift,
storedSpec,
lastSyncDate,
onOpenEndpoint,
isLoading,
onTabSelect
}) => {
const {
pendingAction, setPendingAction,
confirmPendingAction,
handleResetEndpoint,
handleResetAllModified,
handleDeleteEndpoint,
handleDeleteAllLocalOnly,
handleRevertAllChanges,
handleAddMissingEndpoint,
handleAddAllMissing
} = useEndpointActions(collection, collectionDrift, reloadDrift);
const spec = storedSpec || specDrift?.newSpec;
const hasStoredSpec = collectionDrift && !collectionDrift.noStoredSpec;
const hasDrift = hasStoredSpec && (collectionDrift.modified?.length > 0
|| collectionDrift.missing?.length > 0
|| collectionDrift.localOnly?.length > 0);
const renderDriftRow = (endpoint, idx, actions) => (
<ExpandableEndpointRow
key={endpoint.id}
endpoint={endpoint}
collectionPath={collection.pathname}
newSpec={spec}
showDecisions={false}
diffLeftLabel="Last Synced Spec"
diffRightLabel="Current (in collection)"
swapDiffSides
collectionUid={collection.uid}
actions={actions}
/>
);
const modifiedCount = collectionDrift?.modified?.length || 0;
const missingCount = collectionDrift?.missing?.length || 0;
const localOnlyCount = collectionDrift?.localOnly?.length || 0;
const version = specDrift?.storedVersion || storedSpec?.info?.version;
const bannerState = useMemo(() => {
if (hasDrift) {
return {
variant: 'muted',
message: 'Collection has changes since last sync',
badges: { modifiedCount, missingCount, localOnlyCount },
actions: ['revert-all']
};
}
return null;
}, [hasDrift, modifiedCount, missingCount, localOnlyCount, version, lastSyncDate]);
return (
<div className="collection-status-section">
{bannerState && (
<div className={`spec-update-banner ${bannerState.variant}`}>
<div className="banner-left">
{bannerState.variant === 'success'
? <IconCheck size={16} className="status-check-icon" />
: <div className={`status-dot ${bannerState.variant}`} />}
<span className="banner-title">
{bannerState.message}
</span>
{bannerState.badges && (
<span className="banner-details">
{bannerState.badges.modifiedCount > 0 && <StatusBadge status="warning" radius="full">{bannerState.badges.modifiedCount} modified</StatusBadge>}
{bannerState.badges.missingCount > 0 && <StatusBadge status="danger" radius="full">{bannerState.badges.missingCount} deleted</StatusBadge>}
{bannerState.badges.localOnlyCount > 0 && <StatusBadge status="muted" radius="full">{bannerState.badges.localOnlyCount} added</StatusBadge>}
</span>
)}
</div>
{bannerState.actions.includes('revert-all') && (
<div className="banner-actions">
<Button size="sm" variant="ghost" color="danger" onClick={handleRevertAllChanges}>
Revert All to Spec
</Button>
</div>
)}
</div>
)}
{hasDrift && (
<div className="sync-info-notice mt-4">
<IconInfoCircle size={14} className="sync-info-icon" />
<span><span className="whats-updated-title">What's tracked:</span> Changes to parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
</div>
)}
{hasDrift ? (
<div className="mt-5">
{/* Modified in Collection */}
<EndpointChangeSection
title="Modified in Collection"
type="modified"
endpoints={collectionDrift.modified || []}
expandableLayout
collectionUid={collection.uid}
sectionKey="drift-modified"
renderItem={(endpoint, idx) =>
renderDriftRow(endpoint, idx, (
<>
<Button size="xs" variant="ghost" onClick={() => onOpenEndpoint(endpoint.id)} title="Open in tab" icon={<IconExternalLink size={14} />}>
Open
</Button>
<Button size="xs" variant="ghost" onClick={() => handleResetEndpoint(endpoint)} title="Reset to spec" icon={<IconArrowBackUp size={14} />}>
Reset
</Button>
</>
))}
actions={(
<Button
size="xs"
variant="outline"
onClick={handleResetAllModified}
title="Reset all modified endpoints to match the spec"
icon={<IconArrowBackUp size={14} />}
>
Reset All
</Button>
)}
/>
{/* Deleted from Collection */}
<EndpointChangeSection
title="Deleted from Collection"
type="missing"
endpoints={collectionDrift.missing || []}
expandableLayout
collectionUid={collection.uid}
sectionKey="drift-missing"
renderItem={(endpoint, idx) =>
renderDriftRow(endpoint, idx, (
<Button size="xs" variant="ghost" onClick={() => handleAddMissingEndpoint(endpoint)} title="Restore to collection" icon={<IconPlus size={14} />}>
Restore
</Button>
))}
actions={(
<Button
size="xs"
variant="outline"
onClick={handleAddAllMissing}
title="Add all deleted endpoints back to collection"
icon={<IconPlus size={14} />}
>
Restore All
</Button>
)}
/>
{/* Added to Collection */}
<EndpointChangeSection
title="Added to Collection"
type="local-only"
endpoints={collectionDrift.localOnly || []}
expandableLayout
collectionUid={collection.uid}
sectionKey="drift-local-only"
renderItem={(endpoint, idx) =>
renderDriftRow(endpoint, idx, (
<>
<Button size="xs" variant="ghost" onClick={() => onOpenEndpoint(endpoint.id)} title="Open in tab" icon={<IconExternalLink size={14} />}>
Open
</Button>
<Button size="xs" variant="ghost" color="danger" onClick={() => handleDeleteEndpoint(endpoint)} title="Delete endpoint" icon={<IconTrash size={14} />}>
Delete
</Button>
</>
))}
actions={(
<Button
size="xs"
variant="outline"
color="danger"
onClick={handleDeleteAllLocalOnly}
title="Delete all locally added endpoints"
icon={<IconTrash size={14} />}
>
Delete All
</Button>
)}
/>
</div>
) : isLoading ? (
<div className="sync-review-empty-state mt-5">
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
<h4>Checking for updates</h4>
<p>Comparing your collection with the last synced spec...</p>
</div>
) : !hasStoredSpec ? (
<div className="sync-review-empty-state mt-5">
<IconAlertTriangle size={40} className="empty-state-icon" />
<h4>{lastSyncDate ? 'Cannot track collection changes' : 'Waiting for initial sync'}</h4>
<p>{lastSyncDate
? 'The last synced spec is missing. Go to the \'Spec Updates\' tab to restore it, or sync the collection if updates are available to track future changes.'
: 'Once you sync your collection with the spec, local changes will appear here.'}
</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => onTabSelect('spec-updates')}>Go to Spec Updates</Button>
</div>
) : (
<div className="sync-review-empty-state mt-5">
<IconCheck size={40} className="empty-state-icon" />
<h4>No changes in collection</h4>
<p>The collection endpoints match the last synced spec. Nothing to review.</p>
</div>
)}
{/* Action confirmation modal */}
{pendingAction && (
<Modal size="sm" title={pendingAction.title} hideFooter={true} handleCancel={() => setPendingAction(null)}>
<div className="action-confirm-modal">
<p className="confirm-message">{pendingAction.message}</p>
<div className="confirm-actions">
<Button variant="ghost" onClick={() => setPendingAction(null)}>
Cancel
</Button>
<Button
color={pendingAction.type.includes('delete') ? 'danger' : 'primary'}
onClick={confirmPendingAction}
>
Confirm
</Button>
</div>
</div>
</Modal>
)}
</div>
);
};
export default CollectionStatusSection;

View File

@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { IconChevronRight } from '@tabler/icons';
import Modal from 'components/Modal';
import Button from 'ui/Button';
import MethodBadge from 'ui/MethodBadge';
const handleKeyDown = (toggle) => (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
};
const ConfirmGroup = ({ group }) => {
const [expanded, setExpanded] = useState(false);
const toggle = () => setExpanded((prev) => !prev);
return (
<div className={`confirm-group type-${group.type}`}>
<div
className="confirm-group-header"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={handleKeyDown(toggle)}
>
<IconChevronRight size={14} className={`chevron ${expanded ? 'expanded' : ''}`} />
<span className="confirm-group-label">{group.label}</span>
<span className="confirm-group-count">{group.endpoints.length}</span>
</div>
{expanded && (
<div className="endpoints-list">
{group.endpoints.map((ep, i) => (
<div key={ep.id || i} className="endpoint-row">
<MethodBadge method={ep.method} />
<span className="endpoint-path">{ep.path}</span>
{(ep.summary || ep.name) && (
<span className="endpoint-summary">{ep.summary || ep.name}</span>
)}
</div>
))}
</div>
)}
</div>
);
};
const ConfirmSyncModal = ({ groups, onCancel, onSync, isSyncing }) => {
const hasNoChanges = groups.length === 0;
return (
<Modal
size="md"
title="Confirm Sync"
handleCancel={onCancel}
hideFooter={true}
>
<div className="sync-confirm-modal">
{hasNoChanges ? (
<p className="sync-confirm-description">
Your collection is already in sync with the remote spec. Syncing will update the local spec file to match the latest remote version.
</p>
) : (
<>
<p className="sync-confirm-description">
The following changes will be applied to your collection. This action cannot be undone. Are you sure you want to proceed?
</p>
<div className="sync-confirm-groups">
{groups.map((group, idx) => (
<ConfirmGroup key={idx} group={group} />
))}
</div>
</>
)}
<div className="sync-confirm-actions">
<Button variant="ghost" color="secondary" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onSync} loading={isSyncing} disabled={isSyncing}>
{hasNoChanges ? 'Restore Spec File' : 'Confirm & Sync Collection'}
</Button>
</div>
</div>
</Modal>
);
};
export default ConfirmSyncModal;

View File

@@ -0,0 +1,142 @@
import { useState, useRef } from 'react';
import { IconCheck } from '@tabler/icons';
import Button from 'ui/Button';
import { isHttpUrl } from 'utils/url/index';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
const FEATURES = [
'Detect new, modified, and removed endpoints',
'Track local changes against the spec',
'Sync collection with a single click',
'Your tests, assertions, and scripts are preserved during sync'
];
const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError, onConnect }) => {
const [mode, setMode] = useState('url');
const fileInputRef = useRef(null);
return (
<div className="setup-section">
<div className="setup-header">
<h2 className="setup-title">Connect to OpenAPI Spec</h2>
<p className="setup-description">
Keep your collection synchronized with an OpenAPI specification. Changes in the spec will be detected automatically.
</p>
</div>
<form
className="setup-form"
onSubmit={(e) => {
e.preventDefault(); onConnect();
}}
>
<label className="url-label">OpenAPI Specification</label>
<div className="url-row">
<div className="setup-mode-toggle">
<button
type="button"
className={`setup-mode-btn ${mode === 'url' ? 'active' : ''}`}
onClick={() => {
setMode('url'); setSourceUrl('');
}}
>
URL
</button>
<button
type="button"
className={`setup-mode-btn ${mode === 'file' ? 'active' : ''}`}
onClick={() => {
setMode('file'); setSourceUrl('');
}}
>
File
</button>
</div>
{mode === 'url' ? (
<input
type="text"
className="url-input"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
placeholder="https://api.example.com/openapi.json"
/>
) : (
<>
<input
ref={fileInputRef}
type="file"
accept=".json,.yaml,.yml"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
setSourceUrl('');
try {
const data = await parseFileAsJsonOrYaml(file);
if (!isOpenApiSpec(data)) {
setError('The selected file is not a valid OpenAPI 3.x specification');
return;
}
const filePath = window.ipcRenderer.getFilePath(file);
if (filePath) setSourceUrl(filePath);
} catch (err) {
setError(err.message || 'Failed to read the selected file');
}
}}
/>
<button
type="button"
className="url-input file-pick-btn"
onClick={() => fileInputRef.current?.click()}
>
{sourceUrl ? sourceUrl.split(/[\\/]/).pop() : 'Choose file...'}
</button>
</>
)}
<Button
type="submit"
size="sm"
disabled={mode === 'url' ? !isHttpUrl(sourceUrl.trim()) : !sourceUrl.trim()}
loading={isLoading}
>
Connect
</Button>
</div>
<p className="setup-hint">
{mode === 'url'
? 'Supports OpenAPI 3.x specifications in JSON or YAML format'
: 'Select a local OpenAPI/Swagger JSON or YAML file'}
</p>
{error && (
<p className="setup-error">{error}</p>
)}
</form>
<div className="setup-features">
{FEATURES.map((text) => (
<div className="setup-feature" key={text}>
<IconCheck size={16} />
<span>{text}</span>
</div>
))}
</div>
<p className="beta-feedback-inline">
OpenAPI Sync is in Beta we'd love to hear your feedback and suggestions.{' '}
<button
type="button"
className="beta-feedback-link"
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
>
Share feedback
</button>
</p>
</div>
);
};
export default ConnectSpecForm;

View File

@@ -0,0 +1,161 @@
import { useState, useRef } from 'react';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import Modal from 'components/Modal';
import { isHttpUrl } from 'utils/url/index';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const normalizedSourceUrl = (sourceUrl || '').trim();
const isUrl = isHttpUrl(normalizedSourceUrl);
const initialMode = isUrl ? 'url' : 'file';
const [mode, setMode] = useState(initialMode);
const [url, setUrl] = useState(isUrl ? normalizedSourceUrl : '');
const [filePath, setFilePath] = useState(isUrl ? '' : normalizedSourceUrl);
const [autoCheck, setAutoCheck] = useState(openApiSyncConfig?.autoCheck !== false);
const [checkInterval, setCheckInterval] = useState(openApiSyncConfig?.autoCheckInterval || 5);
const [isSaving, setIsSaving] = useState(false);
const fileInputRef = useRef(null);
const intervals = [5, 15, 30, 60];
const effectiveSource = mode === 'file' ? filePath : url.trim();
const canSave = mode === 'file' ? !!effectiveSource : isHttpUrl(effectiveSource.trim());
const handleSave = async () => {
setIsSaving(true);
try {
await onSave({ sourceUrl: effectiveSource, autoCheck, autoCheckInterval: checkInterval });
onClose();
} catch (_) {
// caller (handleSaveSettings) already shows a toast on failure
} finally {
setIsSaving(false);
}
};
return (
<Modal
size="md"
title="Connection Settings"
hideFooter={true}
handleCancel={onClose}
>
<div className="settings-modal">
<div className="settings-body">
<div className="settings-field">
<label className="settings-label">Spec Source</label>
<div className="setup-mode-toggle" style={{ marginBottom: '8px' }}>
<button
type="button"
className={`setup-mode-btn ${mode === 'url' ? 'active' : ''}`}
onClick={() => setMode('url')}
>
URL
</button>
<button
type="button"
className={`setup-mode-btn ${mode === 'file' ? 'active' : ''}`}
onClick={() => setMode('file')}
>
File
</button>
</div>
{mode === 'url' ? (
<input
className="settings-input"
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://api.example.com/openapi.json"
/>
) : (
<>
<input
ref={fileInputRef}
type="file"
accept=".json,.yaml,.yml"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const data = await parseFileAsJsonOrYaml(file);
if (!isOpenApiSpec(data)) {
toast.error('The selected file is not a valid OpenAPI 3.x specification');
return;
}
const path = window.ipcRenderer.getFilePath(file);
if (path) setFilePath(path);
} catch (err) {
toast.error(err.message || 'Failed to read the selected file');
}
}
}}
/>
<button
type="button"
className="settings-input file-pick-btn"
onClick={() => fileInputRef.current?.click()}
>
{filePath ? filePath.split(/[\\/]/).pop() : 'Choose file...'}
</button>
</>
)}
</div>
<div className="settings-field">
<label className="settings-label">Auto-check for updates</label>
<div className="settings-toggle-row">
<div className="toggle-info">
<div className="toggle-description">
Automatically check for spec changes at a regular interval
</div>
</div>
<button
className={`toggle-switch ${autoCheck ? 'active' : ''}`}
onClick={() => setAutoCheck(!autoCheck)}
type="button"
>
<span className="toggle-knob" />
</button>
</div>
</div>
{autoCheck && (
<div className="settings-field">
<label className="settings-label">Check interval</label>
<div className="interval-buttons">
{intervals.map((mins) => (
<button
key={mins}
type="button"
className={checkInterval === mins ? 'active' : ''}
onClick={() => setCheckInterval(mins)}
>
{mins} min
</button>
))}
</div>
</div>
)}
</div>
<div className="settings-footer">
<button className="disconnect-link" onClick={onDisconnect} type="button">
Disconnect sync
</button>
<div className="settings-actions">
<Button variant="ghost" color="secondary" size="sm" onClick={onClose}>Cancel</Button>
<Button size="sm" onClick={handleSave} loading={isSaving} disabled={!canSave || isSaving}>Save</Button>
</div>
</div>
</div>
</Modal>
);
};
export default ConnectionSettingsModal;

View File

@@ -0,0 +1,30 @@
import Button from 'ui/Button';
import Modal from 'components/Modal';
const DisconnectSyncModal = ({ onConfirm, onClose }) => {
return (
<Modal
size="sm"
title="Disconnect Sync"
hideFooter={true}
handleCancel={onClose}
>
<div className="disconnect-modal">
<p className="disconnect-message">
<>Are you sure you want to disconnect OpenAPI sync? </> <br /> <br />
<>This will only disconnect the sync configuration. Your collection will remain intact.</>
</p>
<div className="disconnect-actions">
<Button variant="ghost" color="secondary" onClick={onClose}>
Cancel
</Button>
<Button color="danger" onClick={onConfirm}>
Disconnect
</Button>
</div>
</div>
</Modal>
);
};
export default DisconnectSyncModal;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import MethodBadge from 'ui/MethodBadge';
// Simple endpoint item for non-review mode
const EndpointItem = ({ endpoint, type, actions }) => {
return (
<div className={`endpoint-item type-${type}`}>
<div className="endpoint-row">
<MethodBadge method={endpoint.method} />
<span className="endpoint-path">{endpoint.path}</span>
{endpoint.summary && <span className="endpoint-summary">{endpoint.summary}</span>}
{endpoint.name && !endpoint.summary && <span className="endpoint-summary">{endpoint.name}</span>}
{endpoint.deprecated && <span className="deprecated-tag">deprecated</span>}
{actions && <div className="endpoint-actions">{actions}</div>}
</div>
</div>
);
};
export default EndpointItem;

View File

@@ -0,0 +1,141 @@
import React from 'react';
import isEqual from 'lodash/isEqual';
import get from 'lodash/get';
import VisualDiffUrlBar from 'components/Git/VisualDiffViewer/VisualDiffUrlBar';
import VisualDiffParams from 'components/Git/VisualDiffViewer/VisualDiffParams';
import VisualDiffHeaders from 'components/Git/VisualDiffViewer/VisualDiffHeaders';
import VisualDiffAuth from 'components/Git/VisualDiffViewer/VisualDiffAuth';
import VisualDiffBody from 'components/Git/VisualDiffViewer/VisualDiffBody';
import VisualDiffContent from 'components/Git/VisualDiffViewer/VisualDiffContent/index';
// OpenAPI sync diff section configs (HTTP request sections only)
// Data format matches Git diff format: data.request.url, data.request.params, etc.
const openAPIDiffSectionDataPaths = {
url: ['request.url', 'request.method'],
params: 'request.params',
headers: 'request.headers',
auth: 'request.auth',
body: 'request.body'
};
const openAPISectionHasChanges = (sectionKey, oldData, newData) => {
// For body, only compare the mode and the content for the active mode(s)
// The full request.body object can have extra empty properties that cause false positives
if (sectionKey === 'body') {
const oldBody = get(oldData, 'request.body', {});
const newBody = get(newData, 'request.body', {});
if (oldBody.mode !== newBody.mode) return true;
const mode = oldBody.mode || newBody.mode;
if (!mode || mode === 'none') return false;
return !isEqual(oldBody[mode], newBody[mode]);
}
// For auth, only compare the mode and spec-derived fields for the active auth mode
// Bruno adds extra fields (pkce, credentialsId, tokenQueryKey, etc.) that don't
// come from the OpenAPI spec. Also, the converter generates ALL oauth2 fields
// regardless of grant type, but the collection only stores relevant ones per flow.
if (sectionKey === 'auth') {
const oldAuth = get(oldData, 'request.auth', {});
const newAuth = get(newData, 'request.auth', {});
if (oldAuth.mode !== newAuth.mode) return true;
const mode = oldAuth.mode || newAuth.mode;
if (!mode || mode === 'none') return false;
const oldConfig = oldAuth[mode] || {};
const newConfig = newAuth[mode] || {};
if (mode === 'oauth2') {
// Compare only fields relevant to the specific grant type
const grantType = oldConfig.grantType || newConfig.grantType;
const commonFields = ['grantType', 'scope', 'state'];
const grantTypeFields = {
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl', 'refreshTokenUrl', 'callbackUrl', 'clientId', 'clientSecret'],
implicit: [...commonFields, 'authorizationUrl', 'callbackUrl'],
password: [...commonFields, 'accessTokenUrl', 'refreshTokenUrl', 'clientId', 'clientSecret'],
client_credentials: [...commonFields, 'accessTokenUrl', 'clientId', 'clientSecret']
};
const fields = grantTypeFields[grantType] || commonFields;
return fields.some((field) => !isEqual(oldConfig[field], newConfig[field]));
}
// Other auth modes: compare only spec-relevant fields
const specFields = {
basic: ['username', 'password'],
bearer: ['token'],
apikey: ['key', 'value', 'placement'],
digest: ['username', 'password']
};
const fields = specFields[mode];
if (fields) {
return fields.some((field) => !isEqual(oldConfig[field], newConfig[field]));
}
return !isEqual(oldConfig, newConfig);
}
const paths = openAPIDiffSectionDataPaths[sectionKey];
if (Array.isArray(paths)) {
return paths.some((path) => !isEqual(get(oldData, path), get(newData, path)));
}
return !isEqual(get(oldData, paths), get(newData, paths));
};
const openAPIDiffHasContent = {
url: (data) => data?.request?.url || data?.request?.method,
params: (data) => data?.request?.params && data.request.params.length > 0,
headers: (data) => data?.request?.headers && data.request.headers.length > 0,
auth: (data) => data?.request?.auth && data.request.auth.mode && data.request.auth.mode !== 'none',
body: (data) => {
if (!data?.request?.body) return false;
const mode = data.request.body.mode;
if (!mode || mode === 'none') return false;
return data.request.body.json || data.request.body.text || data.request.body.xml
|| data.request.body.graphql || data.request.body.formUrlEncoded?.length > 0
|| data.request.body.multipartForm?.length > 0;
}
};
const openAPIDiffSections = [
{ key: 'url', title: 'URL', Component: VisualDiffUrlBar, hasContent: openAPIDiffHasContent.url },
{ key: 'params', title: 'Parameters', Component: VisualDiffParams, hasContent: openAPIDiffHasContent.params },
{ key: 'headers', title: 'Headers', Component: VisualDiffHeaders, hasContent: openAPIDiffHasContent.headers },
{ key: 'auth', title: 'Authentication', Component: VisualDiffAuth, hasContent: openAPIDiffHasContent.auth },
{ key: 'body', title: 'Body', Component: VisualDiffBody, hasContent: openAPIDiffHasContent.body }
];
/**
* EndpointVisualDiff - Wrapper around VisualDiffContent for OpenAPI sync
*
* Props:
* - oldData: data from collection (actual current state)
* - newData: data from spec (expected state)
* - leftLabel/rightLabel: custom labels for diff panes
* - swapSides: if true, show spec on left and collection on right
*/
const EndpointVisualDiff = ({
oldData,
newData,
leftLabel = 'Current (in collection)',
rightLabel = 'Expected (from spec)',
swapSides = false
}) => {
const sections = openAPIDiffSections;
// Determine which data goes on which side based on swapSides
const displayOldData = swapSides ? newData : oldData;
const displayNewData = swapSides ? oldData : newData;
return (
<VisualDiffContent
oldData={displayOldData}
newData={displayNewData}
sections={sections}
sectionHasChanges={openAPISectionHasChanges}
oldLabel={leftLabel}
newLabel={rightLabel}
hideUnchanged={true}
/>
);
};
export default EndpointVisualDiff;

View File

@@ -0,0 +1,155 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconChevronRight,
IconChevronDown,
IconCheck,
IconX,
IconLoader2
} from '@tabler/icons';
import { toggleRowExpanded } from 'providers/ReduxStore/slices/openapi-sync';
import MethodBadge from 'ui/MethodBadge';
import { formatIpcError } from 'utils/common/error';
import StatusBadge from 'ui/StatusBadge';
import Help from 'components/Help';
import EndpointVisualDiff from './EndpointVisualDiff';
// Expandable row - can be used with or without decision buttons
const ExpandableEndpointRow = ({ endpoint, decision, onDecisionChange, collectionPath, newSpec, showDecisions = true, decisionLabels, diffLeftLabel, diffRightLabel, swapDiffSides, collectionUid, actions }) => {
const dispatch = useDispatch();
const rowKey = endpoint.id || `${endpoint.method}-${endpoint.path}`;
const isExpanded = useSelector((state) => {
return state.openapiSync?.tabUiState?.[collectionUid]?.expandedRows?.[rowKey] || false;
});
const [isLoading, setIsLoading] = useState(false);
const [diffData, setDiffData] = useState(null);
const [error, setError] = useState(null);
const loadDiffData = useCallback(async () => {
if (diffData) return;
setIsLoading(true);
setError(null);
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:get-endpoint-diff-data', {
collectionPath,
endpointId: endpoint.id,
newSpec
});
if (result.error) {
setError(result.error);
} else {
setDiffData(result);
}
} catch (err) {
setError(formatIpcError(err) || 'Failed to load diff data');
} finally {
setIsLoading(false);
}
}, [collectionPath, endpoint.id, newSpec]);
// Load diff data when expanded (e.g. restored from Redux state)
useEffect(() => {
if (isExpanded && !diffData && !isLoading && !error) {
loadDiffData();
}
}, [isExpanded, diffData, isLoading, loadDiffData, error]);
const handleToggle = () => {
const willExpand = !isExpanded;
if (collectionUid) {
dispatch(toggleRowExpanded({ collectionUid, rowKey }));
}
if (willExpand && !diffData && !isLoading) {
loadDiffData();
}
};
return (
<div className={`endpoint-review-row ${showDecisions ? `decision-${decision}` : ''}`}>
<div
className="review-row-header"
role="button"
tabIndex={0}
aria-expanded={isExpanded}
onClick={handleToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault(); handleToggle();
}
}}
>
<span className="expand-toggle">
{isExpanded ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
</span>
<MethodBadge method={endpoint.method} />
<span className="endpoint-path">{endpoint.path}</span>
{endpoint.summary && <span className="endpoint-name">{endpoint.summary}</span>}
{endpoint.name && !endpoint.summary && <span className="endpoint-name">{endpoint.name}</span>}
{endpoint.conflict && (
<StatusBadge
status="danger"
rightSection={(
<Help icon="info" size={11} placement="top" width={250}>
This endpoint was modified in both the spec and your collection. Choose which version to keep.
</Help>
)}
>
Conflict
</StatusBadge>
)}
{actions && <div className="endpoint-actions" onClick={(e) => e.stopPropagation()}>{actions}</div>}
{showDecisions && onDecisionChange && (
<div className="decision-buttons" onClick={(e) => e.stopPropagation()}>
<button
className={`decision-btn keep ${decision === 'keep-mine' ? 'selected' : ''}`}
onClick={() => onDecisionChange('keep-mine')}
title="Keep your local version"
>
<IconX size={12} /> {decisionLabels?.keep || 'Keep Mine'}
</button>
<button
className={`decision-btn accept ${decision === 'accept-incoming' ? 'selected' : ''}`}
onClick={() => onDecisionChange('accept-incoming')}
title="Accept the spec version"
>
<IconCheck size={12} /> {decisionLabels?.accept || 'Accept Spec'}
</button>
</div>
)}
</div>
{isExpanded && (
<div className="review-row-diff">
{isLoading && (
<div className="diff-loading">
<IconLoader2 size={16} className="spinning" />
<span>Loading diff...</span>
</div>
)}
{error && (
<div className="diff-error">
Error: {error}
</div>
)}
{diffData && !isLoading && !error && (
<EndpointVisualDiff
oldData={diffData.oldData}
newData={diffData.newData}
leftLabel={diffLeftLabel || 'Current (in collection)'}
rightLabel={diffRightLabel || 'Expected (from spec)'}
swapSides={swapDiffSides}
/>
)}
</div>
)}
</div>
);
};
export default ExpandableEndpointRow;

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IconChevronRight } from '@tabler/icons';
import { toggleSectionExpanded } from 'providers/ReduxStore/slices/openapi-sync';
/**
* Collapsible section container for endpoint lists.
* Renders a clickable header (with chevron, dot, title, count) and a body of items.
* Expand/collapse state is persisted in Redux via collectionUid + sectionKey.
*
* @param {string} title - Section heading
* @param {string} type - CSS modifier for color theming (e.g. 'modified', 'missing', 'in-sync')
* @param {Array} endpoints - Items to render; section is hidden when empty
* @param {Function} renderItem - (endpoint, idx) => ReactNode
* @param {boolean} [defaultExpanded=false] - Fallback when no Redux state exists
* @param {boolean} [expandableLayout=false] - Removes max-height scroll constraint on body
* @param {ReactNode} [actions] - Header-right buttons (wrapped in a stopPropagation container)
* @param {string} [subtitle] - Secondary text after the count
* @param {ReactNode} [headerExtra] - Extra content shown in header only when collapsed
* @param {string} collectionUid - Redux key for persisting expand/collapse state
* @param {string} sectionKey - Redux key for persisting expand/collapse state
*/
const EndpointChangeSection = ({
title,
type,
endpoints,
defaultExpanded = false,
actions,
subtitle,
renderItem,
expandableLayout = false,
headerExtra,
collectionUid,
sectionKey
}) => {
const dispatch = useDispatch();
const reduxExpanded = useSelector((state) => {
if (!collectionUid || !sectionKey) return undefined;
return state.openapiSync?.tabUiState?.[collectionUid]?.expandedSections?.[sectionKey];
});
const isExpanded = reduxExpanded !== undefined ? reduxExpanded : defaultExpanded;
if (endpoints.length === 0) return null;
return (
<div className={`change-section type-${type}${isExpanded ? ' expanded' : ''}`}>
<div
className="section-header"
role="button"
tabIndex={0}
onClick={() => {
if (collectionUid && sectionKey) {
dispatch(toggleSectionExpanded({ collectionUid, sectionKey }));
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (collectionUid && sectionKey) {
dispatch(toggleSectionExpanded({ collectionUid, sectionKey }));
}
}
}}
>
<IconChevronRight size={16} className={`chevron ${isExpanded ? 'expanded' : ''}`} />
<span className={`section-dot type-${type}`} />
<span className="section-title">{title}</span>
<span className="section-count">{endpoints.length}</span>
{subtitle && <span className="section-subtitle">{subtitle}</span>}
{!isExpanded && headerExtra}
{actions && <div className="section-actions" onClick={(e) => e.stopPropagation()}>{actions}</div>}
</div>
{isExpanded && (
<div className={`section-body${expandableLayout ? ' expandable-mode' : ''}`}>
{endpoints.map((endpoint, idx) => renderItem(endpoint, idx))}
</div>
)}
</div>
);
};
export default EndpointChangeSection;

View File

@@ -0,0 +1,171 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
IconCopy,
IconDotsVertical,
IconUnlink,
IconSettings,
IconRefresh,
IconCircleCheck,
IconAlertTriangle
} from '@tabler/icons';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import ActionIcon from 'ui/ActionIcon/index';
import MenuDropdown from 'ui/MenuDropdown';
import Help from 'components/Help';
import { isHttpUrl } from 'utils/url/index';
const OpenAPISyncHeader = ({
collection, spec, sourceUrl, syncStatus, onViewSpec,
onOpenSettings, onOpenDisconnect,
onCheck, isLoading
}) => {
const sourceIsLocal = !isHttpUrl(sourceUrl);
const canCheck = !!sourceUrl?.trim();
// Resolve relative file paths to absolute for display
const [displayPath, setDisplayPath] = useState(sourceUrl);
useEffect(() => {
if (sourceIsLocal && sourceUrl) {
window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname)
.then((resolved) => setDisplayPath(resolved))
.catch(() => setDisplayPath(sourceUrl));
} else {
setDisplayPath(sourceUrl);
}
}, [sourceUrl, sourceIsLocal, collection.pathname]);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
const copyUrl = async () => {
if (!sourceUrl) return;
try {
if (sourceIsLocal) {
const absolutePath = await window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname);
await navigator.clipboard.writeText(absolutePath);
} else {
await navigator.clipboard.writeText(sourceUrl);
}
toast.success(sourceIsLocal ? 'Path copied to clipboard' : 'URL copied to clipboard');
} catch (err) {
console.error('Error copying to clipboard:', err);
toast.error('Failed to copy to clipboard');
}
};
const revealInFolder = async () => {
if (!sourceUrl) return;
try {
const absolutePath = await window.ipcRenderer.invoke('renderer:resolve-path', sourceUrl, collection.pathname);
await window.ipcRenderer.invoke('renderer:show-in-folder', absolutePath);
} catch (err) {
console.error('Error revealing in folder:', err);
toast.error('Failed to open in file manager');
}
};
const menuItems = [
{
id: 'settings',
label: 'Edit connection settings',
leftSection: IconSettings,
onClick: onOpenSettings
},
{
id: 'disconnect',
label: 'Disconnect Sync',
leftSection: IconUnlink,
className: 'delete-item',
onClick: onOpenDisconnect
}
];
return (
<div className="spec-info-card">
<div className="spec-info-header">
<div className="spec-title-section">
<div className="spec-title-row">
<span className="spec-title">{title}</span>
</div>
</div>
<div className="spec-header-actions">
<Button
color="secondary"
size="sm"
onClick={onCheck}
disabled={!canCheck}
loading={isLoading}
icon={<IconRefresh size={14} />}
>
Check for updates
</Button>
<Button
color="secondary"
size="sm"
onClick={onViewSpec}
>
View spec
</Button>
<MenuDropdown items={menuItems} placement="bottom-end">
<ActionIcon label="More options">
<IconDotsVertical size={16} strokeWidth={2} />
</ActionIcon>
</MenuDropdown>
</div>
</div>
<div className="spec-url-row">
<span className="spec-url-label">{sourceIsLocal ? 'Source File:' : 'Source URL:'}</span>
{sourceIsLocal ? (
<button
className="spec-url-value spec-file-reveal"
title="Reveal in file manager"
type="button"
onClick={revealInFolder}
>
{displayPath}
</button>
) : (
<a
className="spec-url-value"
href={sourceUrl}
title={sourceUrl}
target="_blank"
rel="noopener noreferrer"
>
{sourceUrl}
</a>
)}
<button className="copy-btn" onClick={copyUrl} title={sourceIsLocal ? 'Copy path' : 'Copy URL'} type="button">
<IconCopy size={12} />
</button>
</div>
<div className="linked-collection-row mt-1">
<span className="spec-url-label">Linked Collection:</span>
<span className="linked-collection-name">{collection.name}</span>
{syncStatus === 'in-sync' && (
<Help
placement="bottom"
width={240}
iconComponent={() => <IconCircleCheck size={14} className="sync-status-icon in-sync" />}
>
Collection is up to date with the spec
</Help>
)}
{syncStatus === 'not-in-sync' && (
<Help
placement="bottom"
width={260}
iconComponent={() => <IconAlertTriangle size={14} className="sync-status-icon not-in-sync" />}
>
Collection is not up to date with the spec
</Help>
)}
</div>
</div>
);
};
export default OpenAPISyncHeader;

View File

@@ -0,0 +1,252 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { getTotalRequestCountInCollection } from 'utils/collections/';
import { countEndpoints } from '../utils';
import moment from 'moment';
import { IconCheck } from '@tabler/icons';
import Button from 'ui/Button';
import Help from 'components/Help';
const capitalize = (str) => str ? str.charAt(0).toUpperCase() + str.slice(1) : str;
const SUMMARY_CARDS = [
{
key: 'total',
label: 'Total in Collection',
color: 'blue',
tooltip: 'Total endpoints in your collection'
},
{
key: 'inSync',
label: 'In Sync with Spec',
color: 'green',
tooltip: 'Endpoints that currently match the latest spec from the source'
},
{
key: 'changed',
label: 'Changed in Collection',
color: 'muted',
tooltip: 'Endpoints modified, deleted, or added locally since last sync',
tab: 'collection-changes'
},
{
key: 'pending',
label: 'Spec Updates Pending',
color: 'amber',
tooltip: 'Spec changes available to sync to your collection',
tab: 'spec-updates'
}
];
const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, remoteDrift, onTabSelect, error, onOpenSettings }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const activeError = error || reduxError;
const version = specMeta?.version;
const endpointCount = specMeta?.endpointCount ?? null;
const lastSyncDate = openApiSyncConfig?.lastSyncDate;
const groupBy = openApiSyncConfig?.groupBy || 'tags';
const autoCheckEnabled = openApiSyncConfig?.autoCheck !== false;
const autoCheckInterval = openApiSyncConfig?.autoCheckInterval || 5;
// Endpoint Summary counts
// Total: from collection items in Redux; In Sync: from remote spec comparison
// Changed/Conflicts: compare against stored spec in AppData (0 on initial sync)
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
const totalInCollection = getTotalRequestCountInCollection(collection);
const inSyncCount = remoteDrift
? (remoteDrift.inSync?.length || 0)
: null;
const changedInCollection = hasDriftData
? (collectionDrift.modified?.length || 0) + (collectionDrift.missing?.length || 0) + (collectionDrift.localOnly?.length || 0)
: 0;
const specUpdatesPending = hasDriftData
? (specDrift?.added?.length || 0) + (specDrift?.modified?.length || 0) + (specDrift?.removed?.length || 0)
: (remoteDrift?.modified?.length || 0) + (remoteDrift?.missing?.length || 0);
// Conflict count: endpoints modified in both spec and collection
const conflictCount = hasDriftData && specDrift?.modified
? (() => {
const localModifiedIds = new Set((collectionDrift.modified || []).map((ep) => ep.id));
return specDrift.modified.filter((ep) => localModifiedIds.has(ep.id)).length;
})()
: 0;
const summaryValues = {
total: totalInCollection,
inSync: inSyncCount,
changed: changedInCollection,
pending: activeError ? null : specDrift ? specUpdatesPending : null
};
const details = [
{ label: 'Spec Version', value: version ? `v${version}` : '' },
{ label: 'Endpoints in Spec', value: endpointCount != null ? endpointCount : '' },
{ label: 'Last Synced At', value: lastSyncDate ? moment(lastSyncDate).fromNow() : '', tooltip: lastSyncDate ? moment(lastSyncDate).format('MMMM D, YYYY [at] h:mm A') : undefined },
{ label: 'Folder Grouping', value: capitalize(groupBy) },
{ label: 'Auto Check for Updates', value: autoCheckEnabled ? `Every ${autoCheckInterval} min` : 'Disabled' }
];
const hasCollectionChanges = changedInCollection > 0;
const hasSpecUpdates = specUpdatesPending > 0;
const bannerState = useMemo(() => {
const versionInfo = (specDrift?.storedVersion && specDrift?.newVersion && specDrift.storedVersion !== specDrift.newVersion)
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
: '';
if (activeError) {
return {
variant: 'danger',
title: 'Failed to check for spec updates',
subtitle: activeError,
buttons: ['open-settings']
};
}
if (specDrift?.storedSpecMissing && !lastSyncDate) {
return {
variant: 'warning',
title: 'Initial sync required — your collection differs from the spec',
subtitle: 'Review the changes and sync to bring your collection up to date.',
buttons: ['review']
};
}
if (hasSpecUpdates && hasCollectionChanges) {
return {
variant: 'warning',
title: `OpenAPI spec has new updates${versionInfo} and the collection has changes`,
subtitle: 'New or changed requests are available. Some collection changes may be overwritten.',
buttons: ['sync', 'changes']
};
}
if (hasSpecUpdates) {
return {
variant: 'warning',
title: `OpenAPI spec has new updates${versionInfo}`,
subtitle: 'New or changed requests are available.',
buttons: ['sync']
};
}
if (specDrift?.storedSpecMissing && lastSyncDate) {
return {
variant: 'warning',
title: 'Last synced spec not found',
subtitle: 'The last synced spec is missing in the storage. Restore the latest spec from the source to track collection changes.',
buttons: ['spec-details']
};
}
if (!hasDriftData) return null;
if (hasCollectionChanges) {
return {
variant: 'muted',
title: 'Collection has changes not in the spec',
subtitle: 'Some requests have been modified or removed and no longer match the spec.',
buttons: ['changes']
};
}
return null;
}, [activeError, hasDriftData, hasSpecUpdates, hasCollectionChanges, specDrift?.storedSpecMissing, specDrift?.storedVersion, specDrift?.newVersion, lastSyncDate]);
return (
<div className="overview-section">
{bannerState && (
<div className={`overview-status-banner ${bannerState.variant}`}>
<div className="banner-text">
<div className="banner-title-row">
{bannerState.variant === 'success'
? <IconCheck size={16} className="status-check-icon" />
: <div className={`status-dot ${bannerState.variant}`} />}
<span className="banner-title">{bannerState.title}</span>
</div>
{bannerState.subtitle && (
<p className="banner-subtitle">{bannerState.subtitle}</p>
)}
</div>
{bannerState.buttons.length > 0 && (
<div className="banner-button-row">
{bannerState.buttons.includes('changes') && (
<Button
size="sm"
variant={bannerState.buttons.includes('sync') ? 'outline' : 'filled'}
color={bannerState.buttons.includes('sync') ? 'secondary' : 'primary'}
onClick={() => onTabSelect('collection-changes')}
>
View Collection Changes
</Button>
)}
{(bannerState.buttons.includes('sync') || bannerState.buttons.includes('review')) && (
<Button size="sm" onClick={() => onTabSelect('spec-updates')}>
Review and Sync Collection
</Button>
)}
{bannerState.buttons.includes('spec-details') && (
<Button variant="outline" size="sm" onClick={() => onTabSelect('spec-updates')}>
Go to Spec Updates
</Button>
)}
{bannerState.buttons.includes('open-settings') && (
<Button variant="outline" size="sm" onClick={onOpenSettings}>
Update connection settings
</Button>
)}
</div>
)}
</div>
)}
<h4 className="overview-section-title mt-5">Endpoint Summary</h4>
<div className="sync-summary-cards">
{SUMMARY_CARDS.map(({ key, label, tooltip, tab, color }) => {
const count = summaryValues[key];
const resolvedColor = count > 0 ? color : 'muted';
const isClickable = tab && count > 0;
return (
<div
className={`summary-card${isClickable ? ' clickable' : ''}`}
key={key}
onClick={isClickable ? () => onTabSelect(tab) : undefined}
>
<span className="card-info-icon">
<Help icon="info" size={12} placement="top" width={220}>{tooltip}</Help>
</span>
<div className="summary-count-row">
<span className={`summary-count ${resolvedColor}`}>{count != null ? count : ''}</span>
{key === 'pending' && conflictCount > 0 && (
<span className="conflict-annotation">({conflictCount} {conflictCount === 1 ? 'conflict' : 'conflicts'})</span>
)}
</div>
<div className="summary-label">
{label}
</div>
</div>
);
})}
</div>
<h4 className="overview-section-title mt-7">Last Synced Spec Details</h4>
<div className="spec-details-grid">
{details.map(({ label, value, tooltip }) => (
<div className="spec-detail-item" key={label}>
<div className="spec-detail-label">{label}</div>
<div className="spec-detail-value">
{value}
{tooltip && (
<Help icon="info" size={11} placement="top" width={200}>{tooltip}</Help>
)}
</div>
</div>
))}
</div>
</div>
);
};
export default OverviewSection;

View File

@@ -0,0 +1,88 @@
import { useRef, useEffect, useState } from 'react';
import { useTheme } from 'providers/Theme/index';
import { IconLoader2 } from '@tabler/icons';
import Modal from 'components/Modal';
import StatusBadge from 'ui/StatusBadge';
const SpecDiffModal = ({ specDrift, onClose }) => {
const diffRef = useRef(null);
const { displayedTheme } = useTheme();
const [isRendering, setIsRendering] = useState(true);
const addedCount = specDrift?.added?.length || 0;
const modifiedCount = specDrift?.modified?.length || 0;
const removedCount = specDrift?.removed?.length || 0;
const versionLabel = specDrift?.versionChanged
? `v${specDrift.storedVersion || '?'} → v${specDrift.newVersion}`
: null;
useEffect(() => {
const { Diff2Html } = window;
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
setIsRendering(false);
return;
}
setIsRendering(true);
const diffHtml = Diff2Html.html(specDrift.unifiedDiff, {
drawFileList: false,
matching: 'lines',
outputFormat: 'side-by-side',
synchronisedScroll: true,
highlight: true,
renderNothingWhenEmpty: false,
colorScheme: displayedTheme
});
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
diffRef.current.innerHTML = diffHtml;
setIsRendering(false);
}, [displayedTheme, specDrift?.unifiedDiff]);
return (
<Modal
size="xl"
title="Spec Diff"
hideFooter
handleCancel={onClose}
>
<div className="spec-diff-modal">
<div className="spec-diff-badges">
{modifiedCount > 0 && <StatusBadge status="warning">Updated: {modifiedCount}</StatusBadge>}
{addedCount > 0 && <StatusBadge status="success">Added: {addedCount}</StatusBadge>}
{removedCount > 0 && <StatusBadge status="danger">Removed: {removedCount}</StatusBadge>}
{versionLabel && <StatusBadge>{versionLabel}</StatusBadge>}
</div>
<p className="spec-diff-subtitle">
{specDrift?.storedSpecMissing
? 'The current spec file is missing. The full remote spec is shown below.'
: 'Side-by-side diff of your current spec vs the updated spec from the spec URL.'}
</p>
<div className="spec-diff-body">
<div className="text-diff-container">
{specDrift?.unifiedDiff ? (
<>
<div className="diff-column-headers">
<span className="diff-column-label">{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}</span>
<span className="diff-column-label">Updated Spec</span>
</div>
{isRendering && (
<div className="text-diff-loading">
<IconLoader2 className="animate-spin" size={20} strokeWidth={1.5} />
<span>Loading diff...</span>
</div>
)}
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
</>
) : (
<div className="text-diff-empty">No text diff available.</div>
)}
</div>
</div>
</div>
</Modal>
);
};
export default SpecDiffModal;

View File

@@ -0,0 +1,149 @@
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import {
IconCheck,
IconRefresh,
IconAlertTriangle,
IconClock
} from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import ConfirmSyncModal from '../ConfirmSyncModal';
import SyncReviewPage from '../SyncReviewPage';
import useSyncFlow from '../hooks/useSyncFlow';
const SpecStatusSection = ({
collection, sourceUrl,
isLoading, error, setError, fileNotFound,
specDrift, storedSpec,
collectionDrift, remoteDrift,
onCheck, onOpenSettings
}) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const lastCheckedAt = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.lastChecked);
const {
isSyncing, showConfirmModal, confirmGroups,
handleRestoreSpec, handleApplySync, cancelConfirmModal, handleConfirmModalSync
} = useSyncFlow({
collection, specDrift, remoteDrift, collectionDrift,
setError, checkForUpdates: onCheck
});
const lastSyncedAt = openApiSyncConfig?.lastSyncDate;
const hasRemoteUpdates = remoteDrift && (
(remoteDrift.missing?.length || 0)
+ (remoteDrift.modified?.length || 0)
+ (remoteDrift.localOnly?.length || 0)
) > 0;
const bannerState = useMemo(() => {
if (fileNotFound) {
return { variant: 'danger', message: `Source file not found at ${sourceUrl}`, actions: ['open-settings'] };
}
if (error || specDrift?.isValid === false) {
return { variant: 'danger', message: error || specDrift?.error || 'Invalid OpenAPI specification', actions: ['open-settings'] };
}
if (!specDrift) {
return null;
}
if (specDrift.storedSpecMissing && !hasRemoteUpdates) {
return null;
}
const hasEndpointUpdates = specDrift.storedSpecMissing
? hasRemoteUpdates
: (specDrift.added?.length || 0) + (specDrift.modified?.length || 0) + (specDrift.removed?.length || 0) > 0;
if (hasEndpointUpdates) {
const versionInfo = (specDrift.storedVersion && specDrift.newVersion && specDrift.storedVersion !== specDrift.newVersion)
? ` (v${specDrift.storedVersion} → v${specDrift.newVersion})`
: '';
return {
variant: 'warning', message: `OpenAPI spec has been updated${versionInfo}`, actions: [],
changes: { added: specDrift.added?.length || 0, modified: specDrift.modified?.length || 0, removed: specDrift.removed?.length || 0 }
};
}
return null;
}, [fileNotFound, error, sourceUrl, specDrift, lastSyncedAt, storedSpec, lastCheckedAt, hasRemoteUpdates]);
return (
<>
{bannerState && (
<div className="spec-status-section">
<div className={`spec-update-banner ${bannerState.variant}`}>
<div className="banner-left">
{bannerState.variant === 'success'
? <IconCheck size={16} className="status-check-icon" />
: <div className={`status-dot ${bannerState.variant}`} />}
<span className="banner-title">
{bannerState.message}
{bannerState.version && (
<> &middot; <code style={{ fontStyle: 'normal' }} className="checked-text">v{bannerState.version}</code></>
)}
{bannerState.lastChecked && (
<span className="checked-text"> &middot; Checked {bannerState.lastChecked}</span>
)}
</span>
{bannerState.changes && (
<span className="banner-details">
{bannerState.changes.modified > 0 && <StatusBadge key="modified" status="warning" radius="full">{bannerState.changes.modified} {bannerState.changes.modified > 1 ? 'endpoints' : 'endpoint'} updated</StatusBadge>}
{bannerState.changes.added > 0 && <StatusBadge key="added" status="success" radius="full">{bannerState.changes.added} {bannerState.changes.added > 1 ? 'endpoints' : 'endpoint'} added</StatusBadge>}
{bannerState.changes.removed > 0 && <StatusBadge key="removed" status="danger" radius="full">{bannerState.changes.removed} {bannerState.changes.removed > 1 ? 'endpoints' : 'endpoint'} removed</StatusBadge>}
</span>
)}
</div>
<div className="banner-actions">
{bannerState.actions.includes('open-settings') && (
<Button variant="ghost" size="sm" onClick={onOpenSettings}>
Update connection settings
</Button>
)}
</div>
</div>
</div>
)}
{(error || fileNotFound || specDrift?.isValid === false) ? (
<div className="sync-review-empty-state mt-5">
<IconAlertTriangle size={40} className="empty-state-icon" />
<h4>Unable to check for updates</h4>
<p>Fix the connection issue above and check again.</p>
</div>
) : specDrift?.storedSpecMissing && openApiSyncConfig?.lastSyncDate && !hasRemoteUpdates ? (
<div className="sync-review-empty-state mt-5">
<IconCheck size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The spec endpoints have not been updated since the last sync. You can restore the spec file to track local collection changes.</p>
<Button className="mt-4" color="warning" onClick={handleRestoreSpec} loading={isSyncing}>
Restore Spec File
</Button>
</div>
) : (
<div className="mt-5">
<SyncReviewPage
specDrift={specDrift}
remoteDrift={remoteDrift}
collectionDrift={collectionDrift}
collectionPath={collection.pathname}
collectionUid={collection.uid}
newSpec={specDrift?.newSpec}
isSyncing={isSyncing}
isLoading={isLoading}
onApplySync={handleApplySync}
/>
</div>
)}
{showConfirmModal && (
<ConfirmSyncModal
groups={confirmGroups}
isSyncing={isSyncing}
onCancel={cancelConfirmModal}
onSync={handleConfirmModalSync}
/>
)}
</>
);
};
export default SpecStatusSection;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,418 @@
import React, { useState, useMemo, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconCheck,
IconX,
IconArrowRight,
IconArrowsDiff,
IconInfoCircle,
IconLoader2
} from '@tabler/icons';
import Button from 'ui/Button';
import StatusBadge from 'ui/StatusBadge';
import EndpointChangeSection from '../EndpointChangeSection';
import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRow';
import ConfirmSyncModal from '../ConfirmSyncModal';
import SpecDiffModal from '../SpecDiffModal';
import Help from 'components/Help';
import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
/**
* Categorize remoteDrift endpoints using three-way merge.
* Uses specDrift and collectionDrift to determine who changed each modified endpoint.
*
* Returns:
* - specAddedEndpoints: new in spec, not yet in collection
* - specUpdatedEndpoints: modified in spec (includes conflicts where both sides changed)
* - localUpdatedEndpoints: modified only in the collection (spec didn't change)
* - specRemovedEndpoints: removed from spec, still in collection
*/
const categorizeEndpoints = (remoteDrift, specDrift, collectionDrift) => {
// Only show endpoints as "New in Spec" if they were actually added to the spec
// (i.e., they appear in specDrift.added). Endpoints the user deleted locally that
// still exist in both stored and remote spec should not appear here — they belong
// in "Collection Changes" only.
const specAddedIds = new Set((specDrift?.added || []).map((ep) => ep.id));
const specAddedEndpoints = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
// Only show endpoints as "Removed from Spec" if they were actually in the stored spec
// (i.e., they appear in specDrift.removed). Locally-added endpoints that were never in
// the spec should not appear here — they belong in "Collection Changes" only.
const specRemovedIds = new Set((specDrift?.removed || []).map((ep) => ep.id));
const specRemovedEndpoints = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
// Build lookup sets to determine who changed each modified endpoint
const specModifiedIds = new Set((specDrift?.modified || []).map((ep) => ep.id));
const localModifiedIds = new Set((collectionDrift?.modified || []).map((ep) => ep.id));
const noMergeBase = collectionDrift?.noStoredSpec;
const specUpdatedEndpoints = [];
const localUpdatedEndpoints = [];
(remoteDrift.modified || []).forEach((ep) => {
// When there's no merge base (noStoredSpec), we can't tell who changed what — treat as spec update
const specChanged = !noMergeBase && specModifiedIds.has(ep.id);
const localChanged = !noMergeBase && localModifiedIds.has(ep.id);
if (!specChanged && localChanged) {
// Only local changed — user modification, spec didn't change
localUpdatedEndpoints.push({
...ep,
source: 'collection-drift',
localAction: 'modified'
});
} else {
// Spec changed, both changed (conflict), no merge base, or sensitivity mismatch
specUpdatedEndpoints.push({
...ep,
source: 'spec-modified',
specAction: 'modified',
...(specChanged && localChanged && { conflict: true, localAction: 'modified' })
});
}
});
return { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints };
};
const SyncReviewPage = ({
specDrift,
remoteDrift,
collectionDrift,
collectionPath,
collectionUid,
newSpec,
isSyncing,
isLoading,
onApplySync
}) => {
const dispatch = useDispatch();
const tabUiState = useSelector(selectTabUiState(collectionUid));
const [showConfirmation, setShowConfirmation] = useState(false);
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
const { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints } = useMemo(() => {
if (!remoteDrift) {
return { specAddedEndpoints: [], specUpdatedEndpoints: [], localUpdatedEndpoints: [], specRemovedEndpoints: [] };
}
return categorizeEndpoints(remoteDrift, specDrift, collectionDrift);
}, [specDrift, remoteDrift, collectionDrift]);
const conflictCount = specUpdatedEndpoints.filter((ep) => ep.conflict).length;
const hasConflicts = conflictCount > 0;
// Track decisions in Redux (persisted across navigations)
const savedDecisions = tabUiState.reviewDecisions || {};
// Compute defaults for any endpoints not yet in Redux
const decisions = useMemo(() => {
const merged = { ...savedDecisions };
// Spec changes: accept-incoming by default, null for conflicts (must resolve manually)
specUpdatedEndpoints.forEach((ep) => {
if (!(ep.id in merged)) merged[ep.id] = ep.conflict ? null : 'accept-incoming';
});
// Local changes: keep-mine (preserved silently, not shown in review)
localUpdatedEndpoints.forEach((ep) => {
if (!(ep.id in merged)) merged[ep.id] = 'keep-mine';
});
// Added + removed endpoints: accept-incoming
[...specAddedEndpoints, ...specRemovedEndpoints].forEach((ep) => {
if (!(ep.id in merged)) merged[ep.id] = 'accept-incoming';
});
return merged;
}, [savedDecisions, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints, specAddedEndpoints]);
// Sync computed defaults back to Redux when they differ from saved state
useEffect(() => {
const hasNewDefaults = Object.keys(decisions).some((id) => !(id in savedDecisions));
if (hasNewDefaults) {
dispatch(setReviewDecisions({ collectionUid, decisions }));
}
}, [decisions, savedDecisions, collectionUid, dispatch]);
const handleDecisionChange = (endpointId, decision) => {
dispatch(setReviewDecision({ collectionUid, endpointId, decision }));
};
// Bulk actions — all spec-driven sections
const decidableEndpoints = useMemo(() => {
return [...specUpdatedEndpoints, ...specAddedEndpoints, ...specRemovedEndpoints];
}, [specUpdatedEndpoints, specAddedEndpoints, specRemovedEndpoints]);
const setBulkDecision = (decision) => {
const newDecisions = {};
decidableEndpoints.forEach((ep) => { newDecisions[ep.id] = decision; });
dispatch(setReviewDecisions({ collectionUid, decisions: newDecisions }));
};
const allAccepted = decidableEndpoints.length > 0
&& decidableEndpoints.every((ep) => decisions[ep.id] === 'accept-incoming');
const allSkipped = decidableEndpoints.length > 0
&& decidableEndpoints.every((ep) => decisions[ep.id] === 'keep-mine');
const unresolvedConflicts = specUpdatedEndpoints.filter((ep) => ep.conflict && !decisions[ep.id]).length;
// Confirmation summary — grouped endpoint lists
const confirmGroups = useMemo(() => {
const groups = [];
const addGroup = (label, type, endpoints) => {
if (endpoints.length > 0) groups.push({ label, type, endpoints });
};
const isAccepted = (ep) => decisions[ep.id] === 'accept-incoming';
const isSkipped = (ep) => decisions[ep.id] === 'keep-mine';
// Accepted — changes that will be applied
addGroup('New endpoints to add', 'add', specAddedEndpoints.filter(isAccepted));
addGroup('Endpoints to update', 'update', specUpdatedEndpoints.filter(isAccepted));
addGroup('Endpoints to delete', 'remove', specRemovedEndpoints.filter(isAccepted));
// Skipped — changes that will be preserved as-is
addGroup('Keeping local version', 'keep', specUpdatedEndpoints.filter((ep) => ep.conflict && isSkipped(ep)));
addGroup('Retaining removed endpoints', 'keep', specRemovedEndpoints.filter(isSkipped));
addGroup('Skipped new endpoints', 'keep', specAddedEndpoints.filter(isSkipped));
addGroup('Keeping current version (skipped updates)', 'keep', specUpdatedEndpoints.filter((ep) => !ep.conflict && isSkipped(ep)));
return groups;
}, [specAddedEndpoints, specUpdatedEndpoints, specRemovedEndpoints, decisions]);
const handleConfirmApply = () => {
setShowConfirmation(false);
// Filter based on decisions
const filteredAddedEndpoints = specAddedEndpoints.filter(
(ep) => decisions[ep.id] === 'accept-incoming'
);
const filteredSpecChanges = specUpdatedEndpoints.filter(
(ep) => !ep.conflict && decisions[ep.id] === 'accept-incoming'
);
// Collect "Not in Spec" endpoints where user chose to remove
const localOnlyIds = specRemovedEndpoints
.filter((ep) => decisions[ep.id] === 'accept-incoming')
.map((ep) => ep.id);
onApplySync({
endpointDecisions: decisions,
localOnlyIds,
// Pass filtered categorized endpoints for performSync to construct the right backend diff
newToCollection: filteredAddedEndpoints,
specUpdates: filteredSpecChanges,
resolvedConflicts: specUpdatedEndpoints.filter((ep) => ep.conflict && decisions[ep.id] === 'accept-incoming'),
localChangesToReset: localUpdatedEndpoints.filter((ep) => decisions[ep.id] === 'accept-incoming')
});
};
const totalChanges = specAddedEndpoints.length + specUpdatedEndpoints.length + localUpdatedEndpoints.length + specRemovedEndpoints.length;
const hasRemoteUpdates = specAddedEndpoints.length + specUpdatedEndpoints.length + specRemovedEndpoints.length > 0;
const buttonLabel = unresolvedConflicts > 0
? `Resolve ${unresolvedConflicts} conflict${unresolvedConflicts !== 1 ? 's and sync' : ' and sync'}`
: !hasRemoteUpdates && specDrift?.storedSpecMissing
? 'Restore Spec File'
: 'Sync Collection';
return (
<div className="sync-review-page sync-mode">
{hasRemoteUpdates && (
<div className="sync-review-header">
<div className="title-row">
<div className="title-left">
<h3 className="review-title">Review Changes</h3>
{totalChanges > 0 && (
<p className="review-subtitle">
Choose to keep the current version or accept the updated one.
</p>
)}
</div>
{(specDrift?.unifiedDiff || decidableEndpoints.length > 0) && (
<div className="bulk-actions">
{specDrift?.unifiedDiff && (
<button className="bulk-btn" onClick={() => setShowSpecDiffModal(true)}>
<IconArrowsDiff size={12} /> View Spec Diff
</button>
)}
{decidableEndpoints.length > 0 && (
<>
<button
className={`bulk-btn ${allSkipped ? 'active' : ''}`}
onClick={() => setBulkDecision('keep-mine')}
>
<IconX size={12} /> Skip All
</button>
<button
className={`bulk-btn ${allAccepted ? 'active' : ''}`}
onClick={() => setBulkDecision('accept-incoming')}
>
<IconCheck size={12} /> Accept All
</button>
</>
)}
</div>
)}
</div>
</div>
)}
<div className="sync-review-body">
{!hasRemoteUpdates ? (
<div className="sync-review-empty-state">
{isLoading ? (
<>
<IconLoader2 size={40} className="empty-state-icon animate-spin" />
<h4>Checking for updates</h4>
<p>Comparing your last synced spec with the latest spec...</p>
</>
) : (
<>
<IconCheck size={40} className="empty-state-icon" />
<h4>No updates from the spec</h4>
<p>The spec endpoints have not been updated since the last sync.</p>
</>
)}
</div>
) : (
<div className="endpoints-review-sections">
{/* === Updates from Spec === */}
{decidableEndpoints.length > 0 && (
<div className="review-group">
<EndpointChangeSection
title="Updated in Spec"
type="spec-modified"
endpoints={specUpdatedEndpoints}
defaultExpanded={true}
expandableLayout
subtitle="The spec has updates for these endpoints"
headerExtra={conflictCount > 0 ? (
<StatusBadge
status="danger"
rightSection={(
<Help icon="info" size={11} placement="top" width={250}>
{`This section has ${conflictCount} endpoint${conflictCount === 1 ? '' : 's'} modified in both the spec and your collection. Expand to review and resolve.`}
</Help>
)}
>
{conflictCount} {conflictCount === 1 ? 'Conflict' : 'Conflicts'}
</StatusBadge>
) : null}
collectionUid={collectionUid}
sectionKey="review-spec-modified"
renderItem={(endpoint, idx) => (
<ExpandableEndpointRow
key={endpoint.id || idx}
endpoint={endpoint}
decision={decisions?.[endpoint.id]}
onDecisionChange={(decision) => handleDecisionChange(endpoint.id, decision)}
collectionPath={collectionPath}
newSpec={newSpec}
showDecisions={true}
decisionLabels={{ keep: 'Keep Current', accept: 'Update' }}
collectionUid={collectionUid}
/>
)}
/>
<EndpointChangeSection
title="New in Spec"
type="added"
endpoints={specAddedEndpoints}
defaultExpanded={true}
expandableLayout
subtitle="New endpoints from the spec"
collectionUid={collectionUid}
sectionKey="review-added"
renderItem={(endpoint, idx) => (
<ExpandableEndpointRow
key={endpoint.id || idx}
endpoint={endpoint}
decision={decisions?.[endpoint.id]}
onDecisionChange={(decision) => handleDecisionChange(endpoint.id, decision)}
collectionPath={collectionPath}
newSpec={newSpec}
showDecisions={true}
decisionLabels={{ keep: 'Skip', accept: 'Add' }}
collectionUid={collectionUid}
/>
)}
/>
<EndpointChangeSection
title="Removed from Spec"
type="removed"
endpoints={specRemovedEndpoints}
defaultExpanded={true}
expandableLayout
subtitle="These endpoints are in your collection but not in the spec"
collectionUid={collectionUid}
sectionKey="review-removed"
renderItem={(endpoint, idx) => (
<ExpandableEndpointRow
key={endpoint.id || idx}
endpoint={endpoint}
decision={decisions?.[endpoint.id]}
onDecisionChange={(decision) => handleDecisionChange(endpoint.id, decision)}
collectionPath={collectionPath}
newSpec={newSpec}
showDecisions={true}
decisionLabels={{ keep: 'Keep', accept: 'Delete' }}
collectionUid={collectionUid}
/>
)}
/>
</div>
)}
</div>
)}
</div>
{hasRemoteUpdates && (
<div className="sync-info-notice mt-4">
<IconInfoCircle size={14} className="sync-info-icon" />
<span><span className="whats-updated-title">What gets updated:</span> Parameters, headers, body and auth will be updated. Tests, scripts, and assertions are always preserved.</span>
</div>
)}
{hasRemoteUpdates && (
<div className="sync-review-bottom-bar mt-4">
<div className="bar-stats">
{totalChanges === 0 && (
<span className="stats-prefix">
{specDrift?.storedSpecMissing ? 'Sync will update the spec file' : 'No endpoint changes to apply'}
</span>
)}
</div>
<div className="bar-actions">
<Button
onClick={totalChanges === 0 ? handleConfirmApply : () => setShowConfirmation(true)}
disabled={unresolvedConflicts > 0 || isSyncing}
loading={isSyncing}
>
{buttonLabel}
{unresolvedConflicts === 0 && <IconArrowRight size={14} style={{ marginLeft: 4 }} />}
</Button>
</div>
</div>
)}
{showConfirmation && (
<ConfirmSyncModal
groups={confirmGroups}
onCancel={() => setShowConfirmation(false)}
onSync={handleConfirmApply}
isSyncing={isSyncing}
/>
)}
{showSpecDiffModal && (
<SpecDiffModal
specDrift={specDrift}
onClose={() => setShowSpecDiffModal(false)}
/>
)}
</div>
);
};
export default SyncReviewPage;

View File

@@ -0,0 +1,165 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
const useEndpointActions = (collection, collectionDrift, reloadDrift) => {
const [pendingAction, setPendingAction] = useState(null);
// Action execution helper — runs IPC call(s), shows toast, reloads drift
const executeEndpointAction = async (ipcCalls, successMsg, errorMsg) => {
try {
const { ipcRenderer } = window;
if (Array.isArray(ipcCalls[0])) {
await Promise.all(ipcCalls.map(([channel, params]) => ipcRenderer.invoke(channel, params)));
} else {
const [channel, params] = ipcCalls;
await ipcRenderer.invoke(channel, params);
}
toast.success(successMsg);
await reloadDrift();
} catch (err) {
console.error(`Error: ${errorMsg}`, err);
toast.error(errorMsg);
}
};
// Confirmation handlers — show modal before executing
const handleResetEndpoint = (endpoint) => {
setPendingAction({
type: 'reset-endpoint',
title: 'Reset Endpoint',
message: `Are you sure you want to reset "${endpoint.method} ${endpoint.path}" to match the spec? Your local changes will be lost.`,
endpoint
});
};
const handleResetAllModified = () => {
if (!collectionDrift?.modified?.length) return;
setPendingAction({
type: 'reset-all-modified',
title: 'Reset All Modified',
message: `Are you sure you want to reset ${collectionDrift.modified.length} modified endpoint(s) to match the spec? Your local changes will be lost.`
});
};
const handleDeleteEndpoint = (endpoint) => {
setPendingAction({
type: 'delete-endpoint',
title: 'Delete Endpoint',
message: `Are you sure you want to delete "${endpoint.method} ${endpoint.path}"? This action cannot be undone.`,
endpoint
});
};
const handleDeleteAllLocalOnly = () => {
if (!collectionDrift?.localOnly?.length) return;
setPendingAction({
type: 'delete-all-local',
title: 'Delete All Local Endpoints',
message: `Are you sure you want to delete ${collectionDrift.localOnly.length} local-only endpoint(s)? This action cannot be undone.`
});
};
const handleRevertAllChanges = () => {
const modifiedCount = collectionDrift?.modified?.length || 0;
const missingCount = collectionDrift?.missing?.length || 0;
const localOnlyCount = collectionDrift?.localOnly?.length || 0;
setPendingAction({
type: 'revert-all',
title: 'Revert All Changes',
message: `Are you sure you want to revert all changes? This will reset ${modifiedCount} modified, restore ${missingCount} missing, and delete ${localOnlyCount} local-only endpoint(s).`
});
};
const handleAddMissingEndpoint = (endpoint) => {
setPendingAction({
type: 'restore-endpoint',
title: 'Restore Endpoint',
message: `Are you sure you want to restore "${endpoint.method} ${endpoint.path}" to your collection?`,
endpoint
});
};
const handleAddAllMissing = () => {
if (!collectionDrift?.missing?.length) return;
setPendingAction({
type: 'restore-all-missing',
title: 'Restore All Missing',
message: `Are you sure you want to restore ${collectionDrift.missing.length} missing endpoint(s) to your collection?`
});
};
// Execute confirmed action
const confirmPendingAction = async () => {
if (!pendingAction) return;
const { type, endpoint } = pendingAction;
setPendingAction(null);
switch (type) {
case 'reset-endpoint':
return executeEndpointAction(
['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: [endpoint] }],
`Reset ${endpoint.method} ${endpoint.path} to spec`,
'Failed to reset endpoint'
);
case 'reset-all-modified':
return executeEndpointAction(
['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: collectionDrift.modified }],
`Reset ${collectionDrift.modified.length} endpoints to spec`,
'Failed to reset endpoints'
);
case 'delete-endpoint':
return executeEndpointAction(
['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: [endpoint] }],
`Deleted ${endpoint.method} ${endpoint.path}`,
'Failed to delete endpoint'
);
case 'delete-all-local':
return executeEndpointAction(
['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: collectionDrift.localOnly }],
`Deleted ${collectionDrift.localOnly.length} local-only endpoints`,
'Failed to delete endpoints'
);
case 'revert-all': {
const calls = [];
if (collectionDrift?.modified?.length > 0) {
calls.push(['renderer:reset-endpoints-to-spec', { collectionPath: collection.pathname, endpoints: collectionDrift.modified }]);
}
if (collectionDrift?.missing?.length > 0) {
calls.push(['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: collectionDrift.missing }]);
}
if (collectionDrift?.localOnly?.length > 0) {
calls.push(['renderer:delete-endpoints', { collectionPath: collection.pathname, collectionUid: collection.uid, endpoints: collectionDrift.localOnly }]);
}
return executeEndpointAction(calls, 'All changes discarded successfully', 'Failed to discard changes');
}
case 'restore-endpoint':
return executeEndpointAction(
['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: [endpoint] }],
`Added ${endpoint.method} ${endpoint.path} to collection`,
'Failed to add endpoint'
);
case 'restore-all-missing':
return executeEndpointAction(
['renderer:add-missing-endpoints', { collectionPath: collection.pathname, endpoints: collectionDrift.missing }],
`Added ${collectionDrift.missing.length} endpoints to collection`,
'Failed to add endpoints'
);
}
};
return {
pendingAction, setPendingAction,
confirmPendingAction,
handleResetEndpoint,
handleResetAllModified,
handleDeleteEndpoint,
handleDeleteAllLocalOnly,
handleRevertAllChanges,
handleAddMissingEndpoint,
handleAddAllMissing
};
};
export default useEndpointActions;

View File

@@ -0,0 +1,412 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isHttpUrl } from 'utils/url/index';
import { flattenItems } from 'utils/collections/index';
import { formatIpcError } from 'utils/common/error';
import { countEndpoints } from '../utils';
const useOpenAPISync = (collection) => {
const dispatch = useDispatch();
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
// Core state
const [sourceUrl, setSourceUrl] = useState(openApiSyncConfig?.sourceUrl || '');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [fileNotFound, setFileNotFound] = useState(false);
const [specDrift, setSpecDrift] = useState(null);
// Collection drift state
const [collectionDrift, setCollectionDrift] = useState(null);
const [remoteDrift, setRemoteDrift] = useState(null);
const [isDriftLoading, setIsDriftLoading] = useState(false);
const [storedSpec, setStoredSpec] = useState(null);
const tabs = useSelector((state) => state.tabs.tabs);
const isConfigured = !!openApiSyncConfig?.sourceUrl;
const updateStoredSpec = (spec) => {
setStoredSpec(spec);
dispatch(setStoredSpecMeta({
collectionUid: collection.uid,
title: spec?.info?.title || null,
version: spec?.info?.version || null,
endpointCount: spec ? countEndpoints(spec) : null
}));
};
// Flatten collection items including nested items in folders
const allHttpItems = useMemo(() => {
return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request');
}, [collection?.items]);
const httpItemCount = useMemo(() => {
return String(allHttpItems.filter((item) => !item.partial && !item.loading).length);
}, [allHttpItems]);
// Map endpoint drift id (METHOD:path) → collection item uid
const endpointUidMap = useMemo(() => {
const normalize = (url) => (url || '')
.replace(/\{\{[^}]+\}\}/g, '')
.replace(/^https?:\/\/[^/]+/, '')
.replace(/\?.*$/, '')
.replace(/{([^}]+)}/g, ':$1')
.replace(/\/+/g, '/')
.replace(/\/$/, '');
const map = {};
allHttpItems.forEach((item) => {
if (item.request?.method && item.request?.url) {
const key = `${item.request.method.toUpperCase()}:${normalize(item.request.url)}`;
map[key] = item.uid;
}
});
return map;
}, [allHttpItems]);
// Open an endpoint in a tab (focus existing or add new), same as sidebar click
const openEndpointInTab = (endpointId) => {
const itemUid = endpointUidMap[endpointId];
if (!itemUid) return;
const existingTab = tabs.find((t) => t.uid === itemUid);
if (existingTab) {
dispatch(focusTab({ uid: itemUid }));
} else {
const item = allHttpItems.find((i) => i.uid === itemUid);
dispatch(addTab({
uid: itemUid,
collectionUid: collection.uid,
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
type: 'request'
}));
}
};
const prevItemCountRef = useRef(httpItemCount);
const isDriftLoadingRef = useRef(false);
const specDriftRef = useRef(specDrift);
const loadCollectionDrift = async ({ clear = false } = {}) => {
if (isDriftLoadingRef.current && !clear) return;
isDriftLoadingRef.current = true;
if (clear) setCollectionDrift(null);
setIsDriftLoading(true);
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname
});
if (!result.error) {
setCollectionDrift(result);
}
} catch (err) {
console.error('Error loading collection drift:', err);
} finally {
isDriftLoadingRef.current = false;
setIsDriftLoading(false);
}
};
const checkForUpdates = async ({ sourceUrlOverride } = {}) => {
const effectiveUrl = (sourceUrlOverride ?? sourceUrl).trim();
if (!effectiveUrl) {
setError('Please enter a URL or select a file');
return;
}
setIsLoading(true);
setError(null);
setFileNotFound(false);
setSpecDrift(null);
setRemoteDrift(null);
setCollectionDrift(null);
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl: effectiveUrl,
environmentContext: {
activeEnvironmentUid: collection.activeEnvironmentUid,
environments: collection.environments,
runtimeVariables: collection.runtimeVariables,
globalEnvironmentVariables: collection.globalEnvironmentVariables
}
});
if (result.errorCode === 'SOURCE_FILE_NOT_FOUND') {
setFileNotFound(true);
setError(result.error);
return;
}
setSpecDrift(result);
updateStoredSpec(result.storedSpec || null);
// Update Redux store so toolbar status stays in sync
dispatch(setCollectionUpdate({
collectionUid: collection.uid,
hasUpdates: result.isValid !== false && result.hasChanges,
diff: result,
error: result.isValid === false ? result.error : null
}));
// Fetch remote drift (remote spec vs collection) for collection-centric categorization
if (result.newSpec) {
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
compareSpec: result.newSpec
});
if (remoteComparison.error) {
console.error('Error computing remote drift:', remoteComparison.error);
setError(remoteComparison.error);
} else {
setRemoteDrift(remoteComparison);
}
}
// Refresh collection drift (stored spec vs collection) — skip if no stored spec
if (!result.storedSpecMissing) {
await loadCollectionDrift({ clear: true });
}
} catch (err) {
console.error('Error checking for updates:', err);
setError(formatIpcError(err) || 'Failed to check for updates');
dispatch(setCollectionUpdate({
collectionUid: collection.uid,
hasUpdates: false,
diff: null,
error: formatIpcError(err) || 'Failed to check for updates'
}));
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (isConfigured) {
checkForUpdates();
}
}, [isConfigured]);
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
useEffect(() => {
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
prevItemCountRef.current = httpItemCount;
loadCollectionDrift();
}
}, [httpItemCount, isConfigured]);
const handleConnect = async () => {
const trimmedUrl = sourceUrl.trim();
if (!trimmedUrl) {
setError('Please enter a URL or select a file');
return;
}
setIsLoading(true);
setError(null);
setFileNotFound(false);
try {
// Validate it's a valid OpenAPI spec before proceeding (URL only; files are validated at picker)
if (isHttpUrl(trimmedUrl)) {
try {
const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl });
if (specType !== 'openapi') {
setError('The URL does not point to a valid OpenAPI 3.x specification');
return;
}
} catch {
setError('The URL does not point to a valid OpenAPI 3.x specification');
return;
}
}
const { ipcRenderer } = window;
// Validate the spec first
const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl: trimmedUrl,
environmentContext: {
activeEnvironmentUid: collection.activeEnvironmentUid,
environments: collection.environments,
runtimeVariables: collection.runtimeVariables,
globalEnvironmentVariables: collection.globalEnvironmentVariables
}
});
if (result.isValid === false) {
setSpecDrift(result);
setError(result.error);
return;
}
// Save sync config (no spec file yet — deferred to first sync unless collection already matches)
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
collectionPath: collection.pathname,
config: {
sourceUrl: trimmedUrl,
groupBy: 'tags',
autoCheck: true,
autoCheckInterval: 5
}
});
// Check if collection already matches the spec
if (result.newSpec) {
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
compareSpec: result.newSpec
});
const isInSync = !drift.error
&& (!drift.missing || drift.missing.length === 0)
&& (!drift.modified || drift.modified.length === 0)
&& (!drift.localOnly || drift.localOnly.length === 0);
if (isInSync) {
// Collection matches — save spec file silently to complete setup
await ipcRenderer.invoke('renderer:save-openapi-spec', {
collectionPath: collection.pathname,
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2)
});
}
}
toast.success('OpenAPI sync connected');
} catch (err) {
console.error('Error connecting OpenAPI sync:', err);
setError(formatIpcError(err) || 'Failed to connect');
} finally {
setIsLoading(false);
}
};
const handleDisconnect = async () => {
try {
const { ipcRenderer } = window;
await ipcRenderer.invoke('renderer:remove-openapi-sync-config', {
collectionPath: collection.pathname,
deleteSpecFile: true
});
setSourceUrl('');
setSpecDrift(null);
setCollectionDrift(null);
setRemoteDrift(null);
setStoredSpec(null);
// Clear Redux state for this collection
dispatch(clearCollectionState({ collectionUid: collection.uid }));
// Close the openapi-spec tab if open (spec file no longer exists)
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
if (specTab) {
dispatch(closeTabs({ tabUids: [specTab.uid] }));
}
toast.success('OpenAPI sync disconnected');
} catch (err) {
console.error('Error disconnecting sync:', err);
toast.error('Failed to disconnect sync');
}
};
// Keep ref in sync so reloadDrift always reads the latest specDrift
specDriftRef.current = specDrift;
// Reload both drifts — passed to useEndpointActions so it can refresh after actions.
// Uses specDriftRef to avoid stale closure over specDrift state.
const reloadDrift = async () => {
await loadCollectionDrift({ clear: true });
// Refresh remoteDrift if we have a remote spec cached from the last check
const currentSpecDrift = specDriftRef.current;
if (currentSpecDrift?.newSpec) {
try {
const { ipcRenderer } = window;
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
collectionPath: collection.pathname,
compareSpec: currentSpecDrift.newSpec
});
if (!remoteComparison.error) {
setRemoteDrift(remoteComparison);
}
} catch (err) {
console.error('Error reloading remote drift:', err);
}
}
};
// Save connection settings from the modal
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
const sourceUrlChanged = newUrl !== openApiSyncConfig?.sourceUrl;
// Validate the spec before saving if source URL changed (URL only; files are validated at picker)
// Kept outside try-catch so validation errors propagate to the caller and the modal stays open
if (sourceUrlChanged && isHttpUrl(newUrl)) {
let specType;
try {
({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl }));
} catch {
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
throw new Error('Invalid OpenAPI specification');
}
if (specType !== 'openapi') {
toast.error('The URL does not point to a valid OpenAPI 3.x specification');
throw new Error('Invalid OpenAPI specification');
}
}
try {
const { ipcRenderer } = window;
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
collectionPath: collection.pathname,
config: {
sourceUrl: newUrl,
autoCheck,
autoCheckInterval
}
});
setSourceUrl(newUrl);
setFileNotFound(false);
toast.success('Settings saved');
// Re-check with new settings — pass newUrl directly to avoid stale closure
await checkForUpdates({ sourceUrlOverride: newUrl });
} catch (err) {
console.error('Error saving settings:', err);
toast.error('Failed to save settings');
}
};
return {
// State
sourceUrl, setSourceUrl,
isLoading,
error, setError,
fileNotFound,
specDrift,
collectionDrift,
remoteDrift,
isDriftLoading,
storedSpec,
// Handlers
checkForUpdates,
handleConnect,
handleDisconnect,
handleSaveSettings,
openEndpointInTab,
reloadDrift
};
};
export default useOpenAPISync;

View File

@@ -0,0 +1,164 @@
import { useState, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import { clearCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync';
import { formatIpcError } from 'utils/common/error';
const useSyncFlow = ({
collection, specDrift, remoteDrift, collectionDrift,
setError, checkForUpdates
}) => {
const dispatch = useDispatch();
const [pendingSyncMode, setPendingSyncMode] = useState(null);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const performSync = async (selections = { localOnlyIds: [], endpointDecisions: {} }, mode = 'sync') => {
setShowConfirmModal(false);
setIsSyncing(true);
setError(null);
const {
localOnlyIds = [], endpointDecisions: decisions = {},
newToCollection, specUpdates, resolvedConflicts, localChangesToReset
} = selections;
try {
const { ipcRenderer } = window;
let filteredDiff;
let localOnlyToRemove;
let driftedToReset;
if (newToCollection) {
// Called from SyncReviewPage with categorized remoteDrift data
filteredDiff = {
...specDrift,
added: newToCollection,
modified: [...(specUpdates || []), ...(resolvedConflicts || [])],
removed: [] // Removals handled via localOnlyToRemove
};
localOnlyToRemove = localOnlyIds.length > 0
? (remoteDrift?.localOnly || []).filter((ep) => localOnlyIds.includes(ep.id))
: [];
driftedToReset = localChangesToReset || [];
} else {
// Called from "Sync Now" (skip review) or ConfirmSyncModal — use specDrift directly
filteredDiff = {
...specDrift,
removed: [] // Removals handled via localOnlyToRemove
};
localOnlyToRemove = localOnlyIds.length > 0
? (remoteDrift?.localOnly || collectionDrift?.localOnly || []).filter((ep) => localOnlyIds.includes(ep.id))
: [];
driftedToReset = collectionDrift?.modified?.filter((ep) => {
const decision = decisions[ep.id];
return decision === 'accept-incoming';
}) || [];
}
await ipcRenderer.invoke('renderer:apply-openapi-sync', {
collectionUid: collection.uid,
collectionPath: collection.pathname,
addNewRequests: mode !== 'spec-only',
removeDeletedRequests: localOnlyIds.length > 0,
diff: filteredDiff,
localOnlyToRemove,
driftedToReset,
mode,
endpointDecisions: decisions
});
setPendingSyncMode(null);
dispatch(clearCollectionUpdate({ collectionUid: collection.uid }));
toast.success(
mode === 'spec-only' ? 'Spec updated successfully'
: mode === 'reset' ? 'Collection reset to spec successfully'
: 'Collection synced successfully'
);
// Re-check to show "up to date" state
await checkForUpdates();
} catch (err) {
console.error('Error syncing collection:', err);
setError(formatIpcError(err) || 'Failed to sync collection');
} finally {
setIsSyncing(false);
}
};
const handleSyncNow = () => {
if (!remoteDrift) return;
setPendingSyncMode('sync');
setShowConfirmModal(true);
};
const handleApplySync = (selections) => {
const mode = pendingSyncMode || 'sync';
setPendingSyncMode(null);
performSync(selections, mode);
};
const cancelConfirmModal = () => {
setShowConfirmModal(false);
setPendingSyncMode(null);
};
// Only treat endpoints as spec changes if they actually changed in the spec
// (not locally-added/deleted endpoints that were never in or removed from the spec)
const specAddedIds = useMemo(() => {
return new Set((specDrift?.added || []).map((ep) => ep.id));
}, [specDrift]);
const specRemovedIds = useMemo(() => {
return new Set((specDrift?.removed || []).map((ep) => ep.id));
}, [specDrift]);
const handleRestoreSpec = () => {
const localOnlyIds = (remoteDrift?.localOnly || [])
.filter((ep) => specRemovedIds.has(ep.id))
.map((ep) => ep.id);
performSync({ localOnlyIds, endpointDecisions: {} }, 'sync');
};
const handleConfirmModalSync = () => {
const localOnlyIds = (remoteDrift?.localOnly || [])
.filter((ep) => specRemovedIds.has(ep.id))
.map((ep) => ep.id);
performSync({
localOnlyIds,
endpointDecisions: {}
}, pendingSyncMode || 'sync');
};
const confirmGroups = useMemo(() => {
if (!remoteDrift) return [];
const groups = [];
const actuallyAdded = (remoteDrift.missing || []).filter((ep) => specAddedIds.has(ep.id));
if (actuallyAdded.length > 0) {
groups.push({ label: 'New endpoints to add', type: 'add', endpoints: actuallyAdded });
}
if (remoteDrift.modified?.length > 0) {
groups.push({ label: 'Endpoints to update', type: 'update', endpoints: remoteDrift.modified });
}
const actuallyRemoved = (remoteDrift.localOnly || []).filter((ep) => specRemovedIds.has(ep.id));
if (actuallyRemoved.length > 0) {
groups.push({ label: 'Endpoints to delete', type: 'remove', endpoints: actuallyRemoved });
}
return groups;
}, [remoteDrift, specAddedIds, specRemovedIds]);
return {
isSyncing, showConfirmModal, confirmGroups,
handleSyncNow, handleRestoreSpec,
handleApplySync, cancelConfirmModal, handleConfirmModalSync
};
};
export default useSyncFlow;

View File

@@ -0,0 +1,213 @@
import { useState, useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { v4 as uuid } from 'uuid';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { setTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import StyledWrapper from './StyledWrapper';
import OpenAPISyncHeader from './OpenAPISyncHeader';
import ConnectSpecForm from './ConnectSpecForm';
import SpecStatusSection from './SpecStatusSection';
import CollectionStatusSection from './CollectionStatusSection';
import ConnectionSettingsModal from './ConnectionSettingsModal';
import DisconnectSyncModal from './DisconnectSyncModal';
import OverviewSection from './OverviewSection';
import useOpenAPISync from './hooks/useOpenAPISync';
const OpenAPISyncTab = ({ collection }) => {
const {
sourceUrl, setSourceUrl,
isLoading,
error, setError,
fileNotFound,
specDrift,
collectionDrift,
remoteDrift,
isDriftLoading,
storedSpec,
checkForUpdates,
handleConnect,
handleDisconnect,
handleSaveSettings,
openEndpointInTab,
reloadDrift
} = useOpenAPISync(collection);
const dispatch = useDispatch();
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const isConfigured = !!openApiSyncConfig?.sourceUrl;
const handleViewSpec = () => {
dispatch(addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'openapi-spec'
}));
};
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [showDisconnectModal, setShowDisconnectModal] = useState(false);
const activeTab = useSelector((state) => state.openapiSync?.tabUiState?.[collection.uid]?.activeTab) || 'overview';
const setActiveTab = useCallback((tab) => {
dispatch(setTabUiState({ collectionUid: collection.uid, activeTab: tab }));
}, [dispatch, collection.uid]);
const hasDriftData = collectionDrift && !collectionDrift.noStoredSpec;
const collectionChangesCount = hasDriftData
? (collectionDrift.modified?.length || 0) + (collectionDrift.missing?.length || 0) + (collectionDrift.localOnly?.length || 0)
: 0;
const specUpdatesCount = hasDriftData
? (specDrift?.added?.length || 0) + (specDrift?.modified?.length || 0) + (specDrift?.removed?.length || 0)
: (remoteDrift?.modified?.length || 0) + (remoteDrift?.missing?.length || 0);
const syncStatus = (() => {
if (isLoading) return 'loading';
if (error) return 'not-in-sync';
if (!hasDriftData) return null;
if (collectionChangesCount > 0 || specUpdatesCount > 0) return 'not-in-sync';
return 'in-sync';
})();
const syncTabs = useMemo(() => [
{ key: 'overview', label: 'Overview' },
{
key: 'collection-changes',
label: 'Collection Changes',
indicator: collectionChangesCount > 0 ? <span className="tab-count">{collectionChangesCount}</span> : null
},
{
key: 'spec-updates',
label: 'Spec Updates',
indicator: specUpdatesCount > 0 ? <span className="tab-count">{specUpdatesCount}</span> : null
}
], [collectionChangesCount, specUpdatesCount]);
return (
<StyledWrapper className="flex flex-col h-full relative px-4 pt-4 overflow-auto">
<div className="sync-page w-full">
{/* Setup form when not configured */}
{!isConfigured && (
<ConnectSpecForm
sourceUrl={sourceUrl}
setSourceUrl={setSourceUrl}
isLoading={isLoading}
error={error}
setError={setError}
onConnect={handleConnect}
/>
)}
{/* Configured: spec header + tabs */}
{isConfigured && (
<>
<OpenAPISyncHeader
collection={collection}
spec={storedSpec || specDrift?.newSpec}
sourceUrl={sourceUrl}
syncStatus={syncStatus}
onViewSpec={handleViewSpec}
onOpenSettings={() => setShowSettingsModal(true)}
onOpenDisconnect={() => setShowDisconnectModal(true)}
onCheck={checkForUpdates}
isLoading={isLoading}
/>
<ResponsiveTabs
tabs={syncTabs}
activeTab={activeTab}
onTabSelect={setActiveTab}
/>
{activeTab === 'overview' && (
<div className="sync-tab-content">
<OverviewSection
collection={collection}
storedSpec={storedSpec}
collectionDrift={collectionDrift}
specDrift={specDrift}
remoteDrift={remoteDrift}
onTabSelect={setActiveTab}
error={error}
onOpenSettings={() => setShowSettingsModal(true)}
/>
<p className="beta-feedback-inline">
OpenAPI Sync is in Beta we'd love to hear your feedback and suggestions.{' '}
<button
type="button"
className="beta-feedback-link"
onClick={() => window?.ipcRenderer?.openExternal('https://github.com/usebruno/bruno/discussions/7401')}
>
Share feedback
</button>
</p>
</div>
)}
{activeTab === 'collection-changes' && (
<div className="sync-tab-content">
<CollectionStatusSection
collection={collection}
collectionDrift={collectionDrift}
reloadDrift={reloadDrift}
specDrift={specDrift}
storedSpec={storedSpec}
lastSyncDate={openApiSyncConfig?.lastSyncDate}
onOpenEndpoint={openEndpointInTab}
isLoading={isDriftLoading || isLoading}
onTabSelect={setActiveTab}
/>
</div>
)}
{activeTab === 'spec-updates' && (
<div className="sync-tab-content">
<SpecStatusSection
collection={collection}
sourceUrl={sourceUrl}
isLoading={isLoading}
error={error}
setError={setError}
fileNotFound={fileNotFound}
specDrift={specDrift}
storedSpec={storedSpec}
collectionDrift={collectionDrift}
remoteDrift={remoteDrift}
onCheck={checkForUpdates}
onOpenSettings={() => setShowSettingsModal(true)}
/>
</div>
)}
</>
)}
</div>
{showSettingsModal && (
<ConnectionSettingsModal
collection={collection}
sourceUrl={sourceUrl}
onSave={handleSaveSettings}
onDisconnect={() => {
setShowSettingsModal(false);
setShowDisconnectModal(true);
}}
onClose={() => setShowSettingsModal(false)}
/>
)}
{showDisconnectModal && (
<DisconnectSyncModal
onConfirm={() => {
setShowDisconnectModal(false);
handleDisconnect();
}}
onClose={() => setShowDisconnectModal(false)}
/>
)}
</StyledWrapper>
);
};
export default OpenAPISyncTab;

View File

@@ -0,0 +1,16 @@
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'];
/**
* Count the number of HTTP endpoints in an OpenAPI spec.
* Returns null if the spec has no paths (e.g. spec is null/undefined).
*/
export const countEndpoints = (spec) => {
if (!spec?.paths) return null;
let count = 0;
for (const path of Object.values(spec.paths)) {
for (const key of Object.keys(path)) {
if (HTTP_METHODS.includes(key.toLowerCase())) count++;
}
}
return count;
};

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
@@ -8,17 +8,19 @@ import debounce from 'lodash/debounce';
import toast from 'react-hot-toast';
import { IconFlask } from '@tabler/icons';
import get from 'lodash/get';
import { BETA_FEATURES as BETA_FEATURE_IDS } from 'utils/beta-features';
/**
* Add beta features here.
* Example:
* {
* id: 'nodevm',
* label: 'Node VM Runtime',
* description: 'Enable Node VM runtime for JavaScript execution in Developer Mode'
* }
* UI metadata for beta features rendered in Preferences.
* IDs must match keys from utils/beta-features.js BETA_FEATURES.
*/
const BETA_FEATURES = [];
const BETA_FEATURES = [
{
id: BETA_FEATURE_IDS.OPENAPI_SYNC,
label: 'OpenAPI Sync',
description: 'Synchronize your Bruno collection with an OpenAPI specification. Detect drift, review changes, and sync with a single click.'
}
];
const Beta = ({ close }) => {
const preferences = useSelector((state) => state.app.preferences);
@@ -45,6 +47,7 @@ const Beta = ({ close }) => {
const betaSchema = generateValidationSchema();
const formik = useFormik({
enableReinitialize: true,
initialValues: generateInitialValues(),
validationSchema: betaSchema,
onSubmit: async (values) => {
@@ -61,22 +64,28 @@ const Beta = ({ close }) => {
dispatch(
savePreferences({
...preferences,
beta: newBetaPreferences
beta: {
...preferences.beta,
...newBetaPreferences
}
})
)
.catch((err) => console.log(err) && toast.error('Failed to update beta preferences'));
}, [dispatch, preferences]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((values) => {
betaSchema.validate(values, { abortEarly: true })
.then((validatedValues) => {
handleSave(validatedValues);
handleSaveRef.current(validatedValues);
})
.catch((error) => {
});
}, 500),
[handleSave, betaSchema]
[betaSchema]
);
// Auto-save when form values change
@@ -85,7 +94,7 @@ const Beta = ({ close }) => {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);

View File

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

View File

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

View File

@@ -38,11 +38,14 @@ const Font = () => {
});
}, [dispatch, preferences]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((font, fontSize) => {
handleSave(font, fontSize);
handleSaveRef.current(font, fontSize);
}, 500),
[handleSave]
[]
);
useEffect(() => {
@@ -52,7 +55,7 @@ const Font = () => {
}
debouncedSave(codeFont, codeFontSize);
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [codeFont, codeFontSize, debouncedSave]);

View File

@@ -0,0 +1,126 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.zoom-field {
position: relative;
}
.zoom-field label {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
display: block;
}
.custom-select {
width: fit-content;
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,128 @@
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 { IconReload } from '@tabler/icons';
import { IconChevronDown, IconCheck } from '@tabler/icons';
import Button from 'ui/Button/index';
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>
<label className="block">Interface Zoom</label>
</div>
<div className="flex flex-row gap-1 items-center mt-2">
<div className="zoom-field" ref={dropdownRef}>
<div className="custom-select" 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
size="sm"
icon={<IconReload />}
color="secondary"
variant="ghost"
onClick={handleResetToDefault}
/>
)}
</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,22 +121,25 @@ const General = () => {
interval: newPreferences.autoSave.interval
},
general: {
defaultCollectionLocation: newPreferences.defaultCollectionLocation
defaultLocation: newPreferences.defaultLocation
}
}))
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
}, [dispatch, preferences]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const debouncedSave = useCallback(
debounce((values) => {
preferencesSchema.validate(values, { abortEarly: true })
.then((validatedValues) => {
handleSave(validatedValues);
handleSaveRef.current(validatedValues);
})
.catch((error) => {
});
}, 500),
[handleSave]
[]
);
useEffect(() => {
@@ -144,7 +147,7 @@ const General = () => {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
@@ -163,11 +166,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 +359,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,4 +1,4 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
@@ -75,41 +75,26 @@ const ProxySettings = ({ close }) => {
});
}, [dispatch, preferences, proxySchema]);
const onUpdateRef = useRef(onUpdate);
onUpdateRef.current = onUpdate;
const debouncedSave = useCallback(
debounce((values) => {
onUpdate(values);
onUpdateRef.current(values);
}, 500),
[onUpdate]
[]
);
const [passwordVisible, setPasswordVisible] = useState(false);
useEffect(() => {
formik.setValues({
disabled: preferences.proxy.disabled || false,
inherit: preferences.proxy.inherit || false,
config: {
protocol: preferences.proxy.config?.protocol || 'http',
hostname: preferences.proxy.config?.hostname || '',
port: preferences.proxy.config?.port || '',
auth: {
disabled: preferences.proxy.config?.auth?.disabled || false,
username: preferences.proxy.config?.auth?.username || '',
password: preferences.proxy.config?.auth?.password || ''
},
bypassProxy: preferences.proxy.config?.bypassProxy || ''
}
});
}, [preferences]);
useEffect(() => {
if (formik.dirty) {
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
debouncedSave.flush();
};
}, [formik.values, formik.dirty, debouncedSave]);
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
return (
<StyledWrapper>

View File

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

View File

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

View File

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

View File

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

View File

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

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