Compare commits

...

57 Commits

Author SHA1 Message Date
Sid
02a573a3e1 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-11 16:30:58 +05:30
Sid
229b49282e ai: coding standards for e2e tests 2026-05-11 16:02:56 +05:30
phoval
4ad51186a1 fix(bruno-electron): interpolate auth headers for GraphQL introspection request (#5560) 2026-05-11 11:13:21 +05:30
Chirag Chandrashekhar
0c7bce3320 fix: Update SaveTransientRequestModal empty state when collections don't exist (#7955) 2026-05-08 16:14:07 +05:30
shubh-bruno
327861b353 fix: persist scroll for assertions (#7947) 2026-05-07 23:30:32 +05:30
Sid
8552b47ead feat: request restore (#7948) 2026-05-07 22:17:11 +05:30
shubh-bruno
2c27e016ef fix: persist codeeditor state json/undo/redo (#7946) 2026-05-07 22:05:38 +05:30
Sid
415b75decb feat: snapshot issues with global tabs (#7942)
* chore: fix for sidebar state

* fix: global and sidebar state sync

* fix: re-priroritise how tab uid is synced
2026-05-07 19:03:02 +05:30
sanish chirayath
f8bf1460bd refactor: revert HeaderList method names to PropertyList conventions (#7931)
* refactor: update headerList methods and translations for consistency

- Renamed methods in req.headerList and res.headerList from 'forEach' to 'each' for consistency with the new API.
- Updated method translations in the Postman converters to reflect the new method names: 'append' to 'add', 'set' to 'upsert', and 'delete' to 'remove'.
- Adjusted related tests to ensure they validate the new method names and functionality.
- Removed deprecated test cases for 'append' and 'set', replacing them with tests for 'add' and 'upsert'.
- Enhanced documentation to clarify the changes in method names and their usage.

* test: add new tests for HeaderList methods and behavior

- Introduced tests to verify that the 'idx' property is undefined in HeaderList, ensuring compliance with the updated API.
- Added tests to confirm that positional methods (prepend, insert, insertAfter) do not exist in HeaderList, reflecting the recent refactor.
- Implemented a test to check that the two-argument form of the 'add' method correctly overwrites existing headers, enhancing the robustness of header management tests.
2026-05-06 22:55:21 +05:30
sanish chirayath
d39d5ef575 refactor: remove idx from HeaderList, extend ReadOnlyPropertyList, add positional method translations (#7917)
* refactor: remove 'idx' method from headerList and update related tests

- Eliminated the 'idx' method from both req.headerList and res.headerList to streamline header management.
- Updated associated tests and documentation to reflect the removal, ensuring clarity in the API usage and maintaining consistency across the header management system.

* refactor: block unimplemented HeaderList methods with error messages

- Added error handling for unimplemented methods in HeaderList, including idx, add, upsert, remove, each, prepend, insert, and insertAfter.
- Each method now throws a descriptive error indicating the appropriate alternative methods to use, enhancing clarity in the API and guiding users towards correct usage.

* refactor: update error message in idx() method of HeaderList for clarity

- Modified the error message in the idx() method to guide users towards using all()[index] or get(name) instead of the unsupported idx() method, enhancing the clarity of the API documentation.

* refactor: update HeaderList to extend ReadOnlyPropertyList and remove unimplemented methods

- Changed HeaderList to extend ReadOnlyPropertyList instead of PropertyList, streamlining its functionality.
- Removed unimplemented methods (prepend, insert, insertAfter) from HeaderList, clarifying the API and guiding users towards using supported methods.
- Updated related tests to reflect these changes, ensuring consistency and accuracy in header management.

* test: add translations for pm.request.headers methods in request tests

- Introduced new tests to validate the translation of pm.request.headers methods (prepend, insert, insertAfter) to their corresponding req.headerList.append method.
- Enhanced existing tests to ensure accurate conversion and functionality of header management in the Bruno converters.

* feat: enhance header translation methods for pm.request.headers

- Added translations for additional pm.request.headers methods (get, has, one, all, count, indexOf, find, filter, each, map, reduce, toObject, clear) to their corresponding req.headerList methods.
- Updated tests to validate the new translations and ensure accurate header management functionality in the Bruno converters.
2026-05-06 18:54:54 +05:30
Pragadesh-45
50d3862ea3 fix: allow empty header names in CLI and gRPC request preparation (#7925) 2026-05-06 18:52:16 +05:30
naman-bruno
39f8c68124 fix: message in ConnectGitRemote and RemoveGitRemote (#7929) 2026-05-06 18:50:30 +05:30
shubh-bruno
ece742cac8 fix: scrollbar restoration (#7926)
* fix: restore scrollbar

* chore: remove comments

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-05-06 17:43:31 +05:30
Sid
20f4e4263a feat: ui state snapshots (#7794)
* feat(snapshot): add session snapshot persistence and restoration

- Add snapshot middleware to persist UI state (tabs, workspaces, environments)
- Add SnapshotManager service in electron for atomic snapshot storage
- Add accessor-based tab serialization using pathname for reliable restoration
- Add loading states for tabs while collections are mounting
- Add hydrateTabs to restore tabs from snapshots on app load
- Add devTools state persistence (console open/height/tab)

* fix(snapshot): preserve unloaded collections and fix async serialization

Make serializeSnapshot async to fetch existing snapshot before saving,
ensuring collections not currently loaded in Redux are preserved. Fix
activeTab serialization to pass collection object instead of just UID.

* refactor(snapshot): rewrite storage to map-based schema with granular IPC

Replace array-based snapshot storage with key-value maps keyed by pathname
for O(1) lookups. Separate tabs into their own top-level map, decoupled
from collections.

- Rewrite SnapshotManager with map-based schema and granular read/write
  methods (getWorkspace, getTabs, setCollection, removeWorkspace, etc.)
- Add 12 granular IPC handlers (renderer:snapshot:*) replacing 4 coarse ones
- Update middleware serialization to produce maps; remove activeCollectionUidCache
  in favor of lastActiveCollectionPathname on workspace objects
- Fix mountCollection passing collectionUid instead of collection to restoreTabs
- Preserve non-active workspace state from existing snapshot on save

* wip

* refactor: allow migration of old snapshot

* refactor: trim down redundancy

* fix: for workspace state

* feat: fix for finalised schema

* fix: schema cleanup

* chore: simplify

* chore: wait on hydration to finish

* chore: snapshot changes to schema

* chore: fix typo

* fix: switch schema for saving and restoring extras

* chore: correctness changes

* chore: add active in the schema check

* chore: fix lint

* chore: comments

* chore: make writes async

* fix: sorting and cross workspace shared collection fixes

* chore: add version

* fix: wait on hydration

* chore: fix backward compat for ui-snapshots

* chore: dead code removal

* Fix optional chaining in snapshot lookup

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
2026-05-06 17:42:46 +05:30
gopu-bruno
0ed2fc82b4 fix: remove dragbar z-index that was bleeding through modals (#7924) 2026-05-06 15:35:20 +05:30
Sid
973ca18e00 Revert "feat: ws multi message (#7719)" (#7921)
This reverts commit a305b41c93.
2026-05-06 13:59:29 +05:30
Pragadesh-45
e92131ff8a fix(cli): skip headers with empty name when building request (#7869) 2026-05-06 13:57:29 +05:30
Pragadesh-45
5cf807b770 feat: implement proxy-aware API fetching using axios instance (#7767) 2026-05-06 13:49:20 +05:30
gopu-bruno
ba42c22aad fix: response pane overflow on vertical split drag for grpc/ws requests (#7916) 2026-05-05 23:02:56 +05:30
sanish chirayath
eb06a3f197 feat: add PropertyList API for req.headers and res.headers (#7673)
* feat: introduce HeaderList for dynamic header management in BrunoRequest and BrunoResponse

- Implemented HeaderList to provide a dynamic API for managing request headers, allowing real-time reflection of changes.
- Updated BrunoRequest and BrunoResponse to utilize HeaderList for header access, enhancing consistency and usability.
- Added a headers proxy to maintain backward compatibility with existing header access patterns.
- Introduced tests for HeaderList to ensure functionality and reliability across various header operations.

* refactor: update createHeadersProxy to accept a getter function for raw headers

- Modified createHeadersProxy to allow passing a function that retrieves raw headers, enhancing flexibility in header management.
- Updated HeaderList to utilize the new getter function, ensuring consistent behavior when headers are modified.
- Added a test to verify that bracket access reflects changes made via BrunoRequest.setHeaders.

* refactor: enhance headers proxy and syncWriteMethods for improved header management

- Updated createHeadersProxy to clarify precedence of PropertyList methods over header names in bracket access.
- Expanded syncWriteMethods in bruno-request shim to include additional methods for better header manipulation.
- Implemented a custom remove method in property-list-bridge to handle function predicates in-VM, improving the native bridge's functionality.

* refactor: update header management in BrunoRequest and BrunoResponse

- Renamed `req.headers` to `req.headerList` for improved clarity and consistency in header operations.
- Enhanced `HeaderList` to support both dynamic and static modes for header management.
- Updated shims for `req` and `res` to reflect the new header access patterns.
- Modified tests to validate the new header access methods and ensure backward compatibility with raw headers.

* refactor: enhance header translation methods for BrunoRequest and BrunoResponse

- Added comprehensive translations for `req.headerList` and `res.headerList` methods to their respective Postman equivalents, improving consistency in header management.
- Updated tests to validate the new translation methods, ensuring accurate conversion between Bruno and Postman header operations.
- Enhanced existing tests to cover additional header manipulation scenarios, reinforcing the reliability of the header management system.

* refactor: streamline headerList implementation in BrunoRequest and BrunoResponse

- Replaced lazy initialization of `headerList` with direct instantiation in both classes for improved performance and clarity.
- Removed redundant getter methods for `headerList`, simplifying the API.
- Updated tests to reflect changes in headerList instantiation and ensure proper functionality.

* refactor: remove header proxy

* feat: add comprehensive headerList tests for request and response

- Introduced multiple test files for `req.headerList` and `res.headerList` methods, covering various operations such as add, remove, clear, populate, and search methods.
- Implemented tests for both dynamic and read-only scenarios, ensuring robust validation of header management functionalities.
- Enhanced existing headerList methods with detailed assertions to improve reliability and maintainability of the header management system.

* feat: expand STATIC_API_HINTS with additional headerList methods for req and res

- Added a comprehensive list of methods for `req.headerList` and `res.headerList` to enhance autocomplete functionality.
- Included various operations such as get, add, remove, and manipulation methods to improve developer experience and usability.

* feat: implement support for disabled headers in headerList

- Added functionality to track disabled headers in `req.headerList` and `res.headerList`, allowing for better management of header states.
- Updated the `prepareRequest` and `prepareGrpcRequest` functions to include a `disabledHeaders` property.
- Enhanced the `HeaderList` class to handle disabled headers, including methods to filter, count, and retrieve them.
- Introduced tests to validate the behavior of disabled headers, ensuring they are correctly included in the header list while being excluded from raw headers.

* feat: enhance HeaderList with case-insensitive key lookups and improved toObject method

- Implemented case-insensitive key lookups for methods such as get, one, has, and indexOf in HeaderList.
- Updated toObject method to support options for excluding disabled headers, handling duplicate keys, and skipping headers with falsy keys.
- Modified toString method to return headers in HTTP wire format, improving consistency with standard practices.
- Added comprehensive tests to validate new functionalities, ensuring robust header management and accurate behavior for both enabled and disabled headers.

* feat: add context binding to iteration methods in HeaderList

- Enhanced iteration methods (each, filter, find, map, reduce) in HeaderList to accept an optional context parameter, allowing for better control over the `this` binding in callback functions.
- Updated the remove method to support context binding for function predicates.
- Added comprehensive tests to validate the new context binding functionality across various iteration methods, ensuring robust header management.

* feat: enhance HeaderList with new methods for string handling and object support

- Added support for accepting an object with a key property in the `has` method, improving header existence checks.
- Updated `add` and `populate` methods to accept "Key: Value" strings and multi-line header strings, enhancing flexibility in header management.
- Modified `toString` method to ensure a trailing newline in the output, aligning with HTTP wire format standards.
- Introduced comprehensive tests to validate new functionalities, ensuring robust header management and accurate behavior for various input types.

* feat: enhance header management with support for disabled headers and context binding

- Updated `mergeHeaders` function to preserve disabled headers from the request item, improving header state management.
- Modified `addBrunoRequestShimToContext` and `addBrunoResponseShimToContext` to wrap eval code in blocks, preventing redeclaration conflicts.
- Enhanced iteration methods in `property-list-bridge` to support context binding, allowing for better control over `this` in callbacks.
- Added comprehensive tests for `req.headerList` and `res.headerList` to validate new functionalities and ensure robust header management.

* feat: improve header merging to preserve disabled headers from request items

- Enhanced the `mergeHeaders` function to retain disabled headers from the last entry in the request tree path, ensuring better management of header states.
- Updated the logic to include disabled headers while merging, improving the overall functionality of header management.

* feat: update header merging logic to consistently handle disabled headers

- Refactored the `mergeHeaders` function to utilize a separate array for disabled headers, ensuring they are preserved during the merging process.
- Simplified the logic for merging headers by directly combining enabled and disabled headers into the request object, enhancing clarity and maintainability.

* refactor: update disabled headers handling in mergeHeaders function

- Changed the implementation of disabled headers from an array to a Map for better performance and consistency.
- Updated the logic in the `mergeHeaders` function to ensure disabled headers are correctly merged into the request object.
- Enhanced tests to validate the handling of disabled headers, including new scenarios for folder-level and request-level overrides.

* feat: enhance mergeHeaders function to support optional inclusion of disabled headers

- Updated the `mergeHeaders` function to accept an options parameter, allowing for the inclusion of disabled headers in the merged result.
- Modified the logic to handle disabled headers more effectively, ensuring they can be returned based on the provided options.
- Adjusted calls to `mergeHeaders` in various modules to utilize the new functionality, improving header management consistency across the application.

* refactor: remove disabledHeaders from prepareGrpcRequest function

- Eliminated the handling of disabledHeaders in the prepareGrpcRequest function to streamline header management.
- Updated the headers processing logic to focus solely on enabled headers, enhancing clarity and reducing complexity.

* feat: add translations for req.headerList and res.headerList methods

- Introduced new tests to translate methods from req.headerList and res.headerList to their corresponding pm.request.headers and pm.response.headers methods.
- Added translations for methods: one, find, toObject, and upsert, enhancing the functionality of the header management system.

* docs: update HeaderList method aliases and add clarification notes

- Enhanced documentation for `prepend`, `append`, `insert`, and `insertAfter` methods to clarify that they are aliases for `add()` and include a note on the lack of support for ordering and duplicates.
- Updated method comments to reflect the internal handling of headers as a plain object, ensuring better understanding of header management behavior.

* fix: remove duplicate headerList initialization in BrunoRequest class

- Eliminated redundant initialization of headerList in the BrunoRequest constructor, ensuring cleaner code and preventing potential issues with header management.
- This change simplifies the constructor logic while maintaining the intended functionality of the headerList.

* refactor: remove unimplemented headerList methods and update documentation

- Removed the unimplemented methods `prepend`, `append`, `insert`, and `insertAfter` from the headerList, ensuring clarity in the API.
- Updated documentation to reflect that these methods are not implemented and provide guidance to use `add()` or `upsert()` instead.
- Adjusted tests to verify that calls to these methods throw appropriate not-implemented errors, enhancing the robustness of header management.

* refactor: remove unused headerList append method from pre-request script

- Eliminated the call to `req.headerList.append()` in the pre-request script, streamlining the header management process.
- This change aligns with the recent refactor to remove unimplemented methods, ensuring clarity and consistency in the API usage.

* test: update error messages for unimplemented headerList methods

- Changed error messages in tests for `append`, `prepend`, `insert`, and `insertAfter` methods to indicate they are "not yet implemented" instead of "not implemented".
- This update improves clarity in the test outputs, aligning with the current state of the headerList API.

* refactor: remove unimplemented headerList methods and update related tests

- Eliminated the unimplemented methods `prepend`, `append`, `insert`, and `insertAfter` from the HeaderList class and corresponding tests, clarifying the API usage.
- Updated the documentation to guide users towards using `add()` or `upsert()` instead.
- Adjusted tests to remove references to these methods, ensuring they accurately reflect the current state of the headerList API.

* refactor: update HeaderList to accept raw request and response objects

- Modified the HeaderList class to accept the raw request and response objects directly, simplifying the initialization process.
- Updated the BrunoRequest and BrunoResponse classes to reflect this change, ensuring consistent handling of headers.
- Enhanced internal methods to utilize the new structure, improving clarity and maintainability of the header management system.

* refactor: enhance HeaderList header management and update tests

- Updated HeaderList to track removed headers for axios interceptor, improving header casing handling.
- Added new syncWriteMethods to the BrunoResponse shim for better integration.
- Enhanced tests to verify the correct tracking of deleted headers and updated expectations for header management functionality.

* refactor: update header translations and improve HeaderList methods

- Removed unused 'idx' translations from both bruno-to-postman and postman-to-bruno translators to streamline header management.
- Enhanced the HeaderList class to skip existing keys when populating headers, ensuring that current values are preserved.
- Updated tests to reflect the new behavior of the populate method, verifying that existing headers are not overwritten and adjusting expectations accordingly.

* refactor: update headerList.populate behavior and adjust tests

- Modified the req.headerList.populate method to add new headers while preserving existing ones, ensuring that current values are not overwritten.
- Updated the associated test to reflect this new behavior, verifying that existing headers remain intact and new headers are correctly added.

* refactor: update HeaderList methods and translations for consistency

- Renamed `each` method to `forEach` in HeaderList for consistency with standard JavaScript array methods.
- Updated header management methods: replaced `add` with `append`, `upsert` with `set`, and `remove` with `delete` to better reflect their functionality.
- Enhanced translations in bruno-to-postman and postman-to-bruno converters to align with the new method names.
- Adjusted tests to verify the new method names and ensure correct header management behavior.

* refactor: remove unused headerList methods and update related shims

- Removed the `entries`, `keys`, and `values` methods from the HeaderList class to streamline the API and eliminate unused functionality.
- Updated the `bruno-request` and `bruno-response` shims to reflect the removal of these methods, ensuring consistency in header management.
- Adjusted tests to remove references to the deleted methods, maintaining alignment with the current state of the headerList API.

* fix: ensure re-added headers are removed from __headersToDelete

- Updated the HeaderList class to remove headers from the __headersToDelete array when they are re-added, preventing incorrect header deletion behavior.
- Added tests to verify that re-added headers do not remain in __headersToDelete and that their values are correctly set in the raw request headers.
2026-05-05 22:32:43 +05:30
Abhishek S Lal
04732fa3d1 feat(api-spec): drag-to-resize split pane with persisted width (#7866)
* Add drag-resize split pane for API Spec viewer

Introduce a drag-to-resize split pane for the API Spec viewer and persist left pane width. Adds a new useDragResize hook to manage dragging state and clamping, plus UI: dragbar styles, a loading state for the Swagger preview (onComplete + loader), and memoization of the Swagger renderer. Wire up persisted widths via Redux: add updateApiSpecPanelLeftPaneWidth (apiSpec slice) and updateApiSpecTabLeftPaneWidth (tabs slice), and propagate leftPaneWidth / onLeftPaneWidthChange through ApiSpecPanel, OpenAPISpecTab, RequestTabPanel and SpecViewer. Misc: pass tab uid into OpenAPISpecTab and add .gstack/ to .gitignore.

* Refactor SpecViewer and OpenAPISpecTab for improved loading and state management

- Updated SpecViewer to enhance loading state handling for Swagger content, ensuring a smoother user experience by preventing flashes of unrendered content.
- Refactored OpenAPISpecTab to streamline environment context management, optimizing the loading process for OpenAPI specifications.
- Simplified the useDragResize hook by removing unnecessary references and improving the handling of drag events, ensuring better performance and responsiveness during resizing actions.

* Enhance useDragResize hook to clamp width seed and improve test coverage

- Updated the useDragResize hook to clamp the width seed value, ensuring it stays within defined bounds during drag events.
- Added a new test case to verify that an out-of-bounds width seed is correctly clamped and persisted on immediate mouseup, enhancing the robustness of the drag-resize functionality.

* Remove .gstack/ from .gitignore

Delete the .gstack/ ignore entry and normalize the packages/bruno-converters/dist entry in .gitignore (deduplicated). No code changes; just tidy up ignore rules.
2026-05-05 22:28:05 +05:30
Abhishek S Lal
69417adcbf feat(openapi-sync): virtualize spec diff rendering + spec change block navigation (#7848)
* feat: implement side-by-side diff viewer for spec synchronization

- Added a new SpecDiffModal component to display differences between current and updated specs.
- Introduced buildRows function to flatten parsed diff data for rendering.
- Created DiffRow component for rendering individual rows in the diff view.
- Implemented highlightCache for efficient word-level diff highlighting.
- Enhanced user experience with navigation controls for changes and loading indicators.
- Added tests for buildRows functionality to ensure accurate diff representation.

* fix: update comments and dependencies for consistency in SpecDiffModal and StyledWrapper

- Added a comment in StyledWrapper.js to clarify the min-height requirement for Virtuoso's fixedItemHeight.
- Updated comment in highlightCache.js to reflect the change from character-level to word-level diff highlighting.
- Adjusted dependency array in SpecDiffModal.js to include cache for improved performance.
2026-05-05 22:26:46 +05:30
Abhishek S Lal
14b2fe1e65 Centralize OpenAPI sync state in Redux (#7876)
* Centralize OpenAPI sync state in Redux

Move per-collection OpenAPI sync state into the Redux slice and update callers. Adds storedSpec and drift maps and reducers (setDrift, clearDrift, setStoredSpec, clearOpenApiSyncTabState), removes the old diff payload from collectionUpdates, and keeps storedSpecMeta. Components and hooks were updated to use inline selectors (state.openapiSync...) instead of exported selectors, and useOpenAPISync was refactored to persist drift/storedSpec to Redux, use the store to read tabs, and call setDrift/setStoredSpec rather than keeping duplicate local state. The collections closeTabs action now clears openapi-sync state for closing openapi-sync tabs so transient state is dropped when tabs are closed. Small variable renames and minor logic adjustments to use the new shape were included.

* Enhance useOpenAPISync hook with comments for clarity on store usage. This update clarifies that tabs are read-only within handlers to prevent unnecessary re-renders when using useSelector.
2026-05-05 22:16:28 +05:30
Dávid Kaya
15cbdb7d10 fix: enable DPI-aware NSIS installer (#7803)
Declare the NSIS installer as DPI-aware so Windows stops bitmap-scaling the setup UI on high-DPI displays.
2026-05-05 21:41:44 +05:30
James
b91f9ba5be fix: preserve axios default Accept header when setting User-Agent (#7820)
Assigning `defaults.headers.common = { 'User-Agent': ... }` replaced the
entire common headers object, nuking axios's built-in default:

  Accept: application/json, text/plain, */*

This caused servers relying on content-negotiation to receive requests
with no Accept header. Fix by extending the existing object with a
property assignment instead.

Add regression tests for both electron and CLI axios instances verifying
that Accept is preserved and User-Agent is set correctly.

Co-authored-by: Pragadesh-45 <temporaryg7904@gmail.com>
2026-05-05 21:38:12 +05:30
gopu-bruno
ab7dd1ff26 feat: auto-require Postman sandbox globals during import (#7878) 2026-05-05 19:47:00 +05:30
sanish chirayath
d332d8e6b2 feat: add ajv-formats support to jsonSchema assertion (#7897)
* Enhance JSON schema validation by integrating ajv-formats for additional format support in tests and runtime assertions.

* fix: update pre-request script to stop execution instead of running it

* fix: ensure newline at end of file in pre-request script for ping.bru

* refactor: update JSON schema validation tests to assert rejection of invalid formats

* refactor: streamline JSON schema validation by using a default AJV instance and enhance tests for various ajvOptions scenarios

* refactor: update JSON schema tests to use more descriptive property names and improve error handling for invalid formats

* feat: add support for Draft-07 JSON Schema validation and improve error handling for unsupported schema versions

* fix: improve error message for unsupported JSON Schema versions in runtime assertions and tests
2026-05-05 11:57:37 +05:30
shubh-bruno
5ced51d163 feat: persist CodeEditor's json state across tab switching (#7797) 2026-05-04 19:12:24 +05:30
naman-bruno
47a1186c4a feat: integrate Git remote for collections (#7879) 2026-05-04 17:04:47 +05:30
Chirag Chandrashekhar
118ba801aa fix: collection settings access, UI overflow fixes, and auto-focus URL bar (#7861)
* fix: collection settings access, UI overflow fixes, and auto-focus URL bar

- Move collection icon outside dropdown; clicking it opens collection
  settings/overview, clicking the name opens the switcher dropdown
- Auto-focus URL bar when creating a transient request (#2919)
- Fix long collection/folder name overflow with ellipsis truncation
- Reduce dropdown width and truncate large collection names
- Simplify breadcrumb collapse: show collection name and last folder,
  collapse middle items into a dropdown
- Fix modal width to prevent shrinking with short collection names
- Show "Create Collection" option when saving a draft with zero collections
- Use IconBox consistently for collection icons

* Replace Chevron component with IconChevronRight

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-30 17:05:40 +05:30
gopu-bruno
8269d51df4 collapsible request/response split in request tab (#7566) 2026-04-30 11:54:41 +05:30
gopu-bruno
4d6e342fdb fix: prevent assertions from returning wrong values during large iteration runs (#7692) 2026-04-30 11:34:51 +05:30
shubh-bruno
0adf7cd90a feat: persist scroll across tabs (#7695)
* fix: persist scroll

* fix: persist scroll

* chore: style

* fix: remove persisted variabled from localstorage on boot

* fix: persist scroll in request tabs

* fix: persist scroll in folder tabs

* fix: hooks for container and editor scrolls

* fix: persist scroll position in response tabs

* fix: persist scroll for different request bodies

* fix: persist scroll for collection tabs

* fix: test cases

* test: scroll persists tests

* tests: resolved coderabbit comments for tests

* tests: resolved coderabbit comments for tests

* fix: remove only

* fix: test cases

* fix: flaky create collection path as name

* move scrollbar tests

* test cases

* test cases

* test cases

* test cases

* test cases

* fix: moved redundant code to common useTrackScroll function

* chore: spaces

* fix: move usetrackscroll to hook

* chore: cleanup un-needed setTimeout

* fix: linting issues

* chore: example fix

* fix: test cases

* fix: test cases

* fix: flaky scroll tests cases

* chore: revert prop name change

* chore: blank commit

* chore: blank commit

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-29 18:19:24 +05:30
sharan-bruno
13a9f9b8ef Fix 1086: Even after clearing the response, the test count keeps on displaying on the tests tab (#7852) 2026-04-29 11:19:43 +05:30
prateek-bruno
ff6ec4a689 fix: "URL encoding off" ignored for multi-param URLs in generated code (#7769) 2026-04-28 19:13:52 +05:30
prateek-bruno
a688effe67 fix: use stable index in requests tab in report (#7867) 2026-04-28 16:26:53 +05:30
Pooja
a305b41c93 feat: ws multi message (#7719) 2026-04-28 15:31:10 +05:30
ryanjbonnell
7febebace5 Merge pull request #7871 from ryanjbonnell/ryanjbonnell-patch-1
Add missing space in help text of Variables Editor window
2026-04-28 15:07:03 +05:30
prateek-bruno
431ea02e16 feat: new selected list component for importing from git (#7813) 2026-04-28 11:51:58 +05:30
prateek-bruno
a04d434f76 feat: add new parameter "apiKeyHeaderName" for "apikey" mode (#7762) 2026-04-28 11:23:16 +05:30
prateek-bruno
ac2cff90f0 fix: make "Remove Collection" consistent with "Remove Workspace" (#7750) 2026-04-28 11:14:08 +05:30
shubh-bruno
87aefe9849 fix: send-request shortcut (#7853)
* fix: send-request shortcut

* fix: test cases

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: phubadeepjs <ID+phubadeepjs@users.noreply.github.com>
2026-04-27 19:19:54 +05:30
shubh-bruno
9361393a49 fix/search bar in codeeditor (#7841) 2026-04-27 11:25:26 +05:30
ganesh
c91e5fd9c7 fixed noproxy flag (#7586) 2026-04-24 15:24:45 +05:30
ganesh
a7744ee23e update readme file with image (#7721)
* update readme file with image

* updat image with yml extension
2026-04-24 14:56:23 +05:30
Pooja
1f5f726e17 refactor(table): virtualise tables for perf for EditableTable components (#7810) 2026-04-24 13:06:28 +05:30
Steven
9501a14bf8 Fix: Variables Text Missing Whitespace (#7844)
* Added missing space to render text properly
* Switching to the space method used above in file
2026-04-24 00:38:48 +05:30
sanish chirayath
e12b736516 feat: add custom jsonBody Chai assertion + simplify Postman translation (#7299)
* feat: enhance jsonBody translation handling in Postman to Bruno converter

* feat: implement jsonBody assertion for Postman compatibility and enhance translation handling

- Added custom Chai assertion for jsonBody to validate JSON structures, including deep equality and nested properties.
- Updated Postman to Bruno translation logic to utilize the new jsonBody assertion, improving the handling of response validations.
- Enhanced test coverage for jsonBody translations, including positive and negative cases for nested properties and deep equality checks.

* feat: enhance jsonBody assertion translations for Postman compatibility

- Added translations for `pm.response.not.to.have.jsonBody` and `pm.response.to.have.not.jsonBody` to the Postman to Bruno converter.
- Updated tests to cover new translation cases, ensuring proper handling of negation scenarios for JSON body assertions.
- Enhanced existing jsonBody assertion logic to support new translation patterns, improving overall compatibility with Postman syntax.

* feat: add advanced path parsing for jsonBody assertions

- Introduced a new `parsePath` function to handle various property path formats, including dot notation, numeric brackets, and quoted keys.
- Updated the `getNestedValue` function to utilize the new path parsing logic, enhancing the robustness of jsonBody assertions.
- Expanded test cases to cover a wide range of scenarios, including edge cases for bracket notation and keys with special characters.

* docs: add examples for parsePath function in jsonBody assertions

- Enhanced documentation for the `parsePath` function by including examples of various property path formats.
- Updated comments in both `assert-runtime.js` and `test.js` to clarify the handling of dot notation, numeric brackets, and quoted keys.

* fix: improve path handling in assertions for quoted keys

- Updated condition checks in `assert-runtime.js` and `test.js` to ensure proper handling of quoted keys in path parsing.
- Enhanced robustness of the path parsing logic to prevent potential out-of-bounds errors.
2026-04-22 12:59:32 +05:30
sanish chirayath
c4dc0bc10d feat: add JSON Schema validation support with custom chai assertion (#7301)
* feat: add JSON Schema validation support with custom chai assertion

- Introduced a new custom assertion for JSON Schema validation in chai, allowing users to validate response bodies against defined schemas.
- Updated the postman translation logic to translate `pm.response.to.have.jsonSchema` to the new assertion format.
- Enhanced tests to cover various scenarios for JSON Schema validation, ensuring accurate translations and functionality.
- Updated package dependencies to include the latest versions of relevant libraries.

* refactor: enhance JSON Schema validation assertion and add comprehensive test cases

* chore: add @rollup/plugin-json dependency and enhance JSON Schema validation tests

- Added @rollup/plugin-json as a development dependency in package.json and package-lock.json.
- Introduced new test cases for JSON Schema validation, covering various scenarios including valid schema matching, type mismatches, and required field checks.
- Updated existing assertions to utilize the new validation capabilities.

* refactor: streamline JSON Schema validation with default Ajv instance

- Updated the custom chai assertion for JSON Schema validation to utilize a default Ajv instance, improving consistency and reducing redundancy in the code.
- Enhanced the error messages in the assertion to include the actual data being validated, providing clearer feedback during validation failures.

* refactor: improve error messaging in JSON Schema validation assertion

- Enhanced the custom chai assertion for JSON Schema validation to provide clearer error messages by including a stringified version of the data being validated, improving feedback during validation failures.

* refactor: simplify Ajv instance creation in JSON Schema validation

- Removed the default Ajv instance and streamlined the creation of Ajv instances in the custom chai assertion for JSON Schema validation, ensuring consistent handling of ajvOptions across the codebase.

* feat: add support for negated JSON Schema assertions in Postman translations

- Introduced translations for `pm.response.to.not.have.jsonSchema`, `pm.response.not.to.have.jsonSchema`, and `pm.response.to.have.not.jsonSchema` to the new assertion format using `expect`.
- Enhanced the translation logic to handle these new patterns and added corresponding test cases to ensure accurate functionality.
- Updated existing tests to cover various scenarios for negated assertions, improving overall test coverage for JSON Schema validation.

* fix: improve error handling in JSON Schema validation assertions

- Added error handling for JSON schema compilation in the custom chai assertion, ensuring that any compilation errors are caught and reported with a clear message.
- Updated tests to verify that malformed schemas correctly trigger assertion errors, enhancing the robustness of JSON Schema validation.
2026-04-21 17:15:33 +05:30
shubh-bruno
9e92e6f04e fix: shortcut in query builder (#7812)
* fix: enter shortcut for query builder

* chore: remove comments

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-21 16:52:39 +05:30
shubh-bruno
e3e0b688e3 fix: shortcut for query builder (#7805)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-21 11:44:20 +05:30
shubh-bruno
c6281d329a fix: layout glitches on multiline environment variables when scrolling (#7732)
* fix: glitch while scrolling multiline variables in environment

* fix: placeholder issues

* fix: buttons loading at ease

* chore: smoothen the animation

* chore: select between a decent chunk for overscan

* chore: remove magic number

* chore: stick to checking nulls

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-16 16:18:53 +05:30
shubh-bruno
9822ceec6c fix: qol fixes for keybindings (#7709)
* fix: keybindings issues

* chore: let SingleLineEditor handle it's own handleSubmit

* fix: resolve issues

* fix: disable reset default if none are changed

* fix: exlude transient request from reopen last closed tabs

* fix: updated all hardcoded colors to respective theme colors

* chore: pick color from theme

---------

Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
Co-authored-by: Sid <siddharth@usebruno.com>
2026-04-15 15:17:42 +05:30
gopu-bruno
b733d0e6f8 fix: generate examples for description only responses in swagger 2.0 converter (#7717) 2026-04-14 18:48:37 +05:30
Sid
ebf60e0c18 fix: avoid round trip loss of annotation data (#7730)
* fix: avoid round trip loss of annotation data

* feat: update types for file , multipart and tests for the same

* chore: optional

* chore: fix body:file annotation

* chore: remove log
2026-04-14 18:47:39 +05:30
shubh-bruno
c5529a9470 fix: response filter in runner (#7747)
Co-authored-by: shubh-bruno <shubh-bruno@shubh-bruno.local>
2026-04-14 15:43:16 +05:30
Pooja
ce3f9a4185 fix: no environment alignment (#7580)
* fix: no environment alignment

* fix
2026-04-14 15:32:30 +05:30
281 changed files with 20893 additions and 1424 deletions

2
.gitignore vendored
View File

@@ -67,4 +67,4 @@ AGENTS.md
packages/bruno-filestore/dist
packages/bruno-requests/dist
packages/bruno-schema-types/dist
packages/bruno-converters/dist
packages/bruno-converters/dist

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 615 KiB

137
package-lock.json generated
View File

@@ -8957,48 +8957,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@jitl/quickjs-ffi-types": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.29.2.tgz",
"integrity": "sha512-069uQTiEla2PphXg6UpyyJ4QXHkTj3S9TeXgaMCd8NDYz3ODBw5U/rkg6fhuU8SMpoDrWjEzybmV5Mi2Pafb5w==",
"license": "MIT"
},
"node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.29.2.tgz",
"integrity": "sha512-YdRw2414pFkxzyyoJGv81Grbo9THp/5athDMKipaSBNNQvFE9FGRrgE9tt2DT2mhNnBx1kamtOGj0dX84Yy9bg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/@jitl/quickjs-wasmfile-debug-sync": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.29.2.tgz",
"integrity": "sha512-VgisubjyPMWEr44g+OU0QWGyIxu7VkApkLHMxdORX351cw22aLTJ+Z79DJ8IVrTWc7jh4CBPsaK71RBQDuVB7w==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.29.2.tgz",
"integrity": "sha512-sf3luCPr8wBVmGV6UV8Set+ie8wcO6mz5wMvDVO0b90UVCKfgnx65A1JfeA+zaSGoaFyTZ3sEpXSGJU+6qJmLw==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/@jitl/quickjs-wasmfile-release-sync": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.29.2.tgz",
"integrity": "sha512-UFIcbY3LxBRUjEqCHq3Oa6bgX5znt51V5NQck8L2US4u989ErasiMLUjmhq6UPC837Sjqu37letEK/ZpqlJ7aA==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -27099,31 +27057,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quickjs-emscripten": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.29.2.tgz",
"integrity": "sha512-SlvkvyZgarReu2nr4rkf+xz1vN0YDUz7sx4WHz8LFtK6RNg4/vzAGcFjE7nfHYBEbKrzfIWvKnMnxZkctQ898w==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-wasmfile-debug-asyncify": "0.29.2",
"@jitl/quickjs-wasmfile-debug-sync": "0.29.2",
"@jitl/quickjs-wasmfile-release-asyncify": "0.29.2",
"@jitl/quickjs-wasmfile-release-sync": "0.29.2",
"quickjs-emscripten-core": "0.29.2"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/quickjs-emscripten-core": {
"version": "0.29.2",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.29.2.tgz",
"integrity": "sha512-jEAiURW4jGqwO/fW01VwlWqa2G0AJxnN5FBy1xnVu8VIVhVhiaxUfCe+bHqS6zWzfjFm86HoO40lzpteusvyJA==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.29.2"
}
},
"node_modules/ramda": {
"version": "0.30.1",
"resolved": "https://registry.npmjs.org/ramda/-/ramda-0.30.1.tgz",
@@ -35882,7 +35815,7 @@
"nanoid": "3.3.8",
"node-fetch": "^2.7.0",
"path": "^0.12.7",
"quickjs-emscripten": "^0.29.2",
"quickjs-emscripten": "^0.32.0",
"tv4": "^1.3.0",
"uuid": "^10.0.0",
"xml-formatter": "^3.5.0",
@@ -35891,11 +35824,54 @@
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-terser": "^1.0.0",
"rollup": "3.30.0"
}
},
"packages/bruno-js/node_modules/@jitl/quickjs-ffi-types": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
"license": "MIT"
},
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-debug-asyncify": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz",
"integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-debug-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz",
"integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-release-asyncify": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz",
"integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/@jitl/quickjs-wasmfile-release-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz",
"integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -35945,6 +35921,31 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"packages/bruno-js/node_modules/quickjs-emscripten": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz",
"integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0",
"@jitl/quickjs-wasmfile-debug-sync": "0.32.0",
"@jitl/quickjs-wasmfile-release-asyncify": "0.32.0",
"@jitl/quickjs-wasmfile-release-sync": "0.32.0",
"quickjs-emscripten-core": "0.32.0"
},
"engines": {
"node": ">=16.0.0"
}
},
"packages/bruno-js/node_modules/quickjs-emscripten-core": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"packages/bruno-js/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",

View File

@@ -1,14 +1,15 @@
import { memo } from 'react';
import SwaggerUI from 'swagger-ui-react';
import StyledWrapper from './StyledWrapper';
const Swagger = ({ spec }) => {
const Swagger = ({ spec, onComplete }) => {
return (
<StyledWrapper>
<div className="swagger-root w-full">
<SwaggerUI spec={spec} />
<SwaggerUI spec={spec} onComplete={onComplete} />
</div>
</StyledWrapper>
);
};
export default Swagger;
export default memo(Swagger);

View File

@@ -1,26 +1,31 @@
import React, { useState, useEffect, Suspense } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { IconDeviceFloppy } from '@tabler/icons';
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
import CodeEditor from './FileEditor/CodeEditor/index';
import Swagger from './Renderers/Swagger';
import { useDragResize } from 'hooks/useDragResize';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 450;
/**
* 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)
* - content (string) The spec content (YAML/JSON string)
* - readOnly (boolean) If true, editor is not editable and save icon is hidden
* - onSave (fn) Called with current editor content on save (editable mode only)
* - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default
* - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp)
*/
const SpecViewer = ({ content, readOnly, onSave }) => {
const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => {
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]);
@@ -31,38 +36,85 @@ const SpecViewer = ({ content, readOnly, onSave }) => {
if (onSave) onSave(editorContent);
};
const mainSectionRef = useRef(null);
const { dragging, dragWidth, dragbarProps } = useDragResize({
containerRef: mainSectionRef,
width: leftPaneWidth,
onWidthChange: onLeftPaneWidthChange,
minLeft: MIN_LEFT_PANE_WIDTH,
minRight: MIN_RIGHT_PANE_WIDTH
});
const effectiveWidth = dragging ? dragWidth : leftPaneWidth;
const leftPaneStyle = effectiveWidth != null
? { width: `${effectiveWidth}px`, flexShrink: 0 }
: { flex: '1 1 50%', minWidth: 0 };
const [swaggerReady, setSwaggerReady] = useState(false);
useEffect(() => {
setSwaggerReady(false);
}, [content]);
const handleSwaggerComplete = useCallback(() => {
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
// before hiding the loader — avoids a flash of unrendered content.
requestAnimationFrame(() => {
requestAnimationFrame(() => setSwaggerReady(true));
});
}, []);
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'
}`}
/>
)}
<section
ref={mainSectionRef}
className={`main flex flex-grow pl-4 relative ${dragging ? 'dragging' : ''}`}
>
<div
className="api-spec-left-pane flex flex-grow relative h-full"
style={leftPaneStyle}
>
<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 className="dragbar-wrapper" {...dragbarProps}>
<div className="dragbar-handle" />
</div>
<div
className="api-spec-right-pane relative"
style={{ flex: '1 1 50%', minWidth: 0 }}
>
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
<Swagger spec={content} onComplete={handleSwaggerComplete} />
</div>
{!swaggerReady && (
<div
className="absolute inset-0 flex items-center justify-center gap-2"
style={{ background: theme.bg }}
>
<div className="flex items-center justify-center gap-2 opacity-70">
<IconLoader2 size={20} className="animate-spin" />
<span>Generating preview</span>
</div>
</div>
</div>
<div className="col-span-1">
<Suspense fallback="">
<Swagger spec={content} />
</Suspense>
</div>
)}
</div>
</section>
);

View File

@@ -17,6 +17,35 @@ const StyledWrapper = styled.div`
.react-tooltip {
z-index: 10;
}
section.main.dragging {
cursor: col-resize;
user-select: none;
}
div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
padding: 0;
cursor: col-resize;
background: transparent;
position: relative;
flex-shrink: 0;
div.dragbar-handle {
display: flex;
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
}
&:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
`;
export default StyledWrapper;

View File

@@ -1,11 +1,11 @@
import React, { forwardRef, useRef } from 'react';
import React, { forwardRef, useRef, useCallback } from 'react';
import find from 'lodash/find';
import { useSelector, useDispatch } from 'react-redux';
import { IconFileCode, IconDots } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import SpecViewer from './SpecViewer';
import Dropdown from 'components/Dropdown';
import { openApiSpec, saveApiSpecToFile } from 'providers/ReduxStore/slices/apiSpec';
import { openApiSpec, saveApiSpecToFile, updateApiSpecPanelLeftPaneWidth } from 'providers/ReduxStore/slices/apiSpec';
import { useState } from 'react';
import CreateApiSpec from 'components/Sidebar/ApiSpecs/CreateApiSpec';
import toast from 'react-hot-toast';
@@ -21,7 +21,16 @@ const ApiSpecPanel = () => {
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid);
const { filename, pathname, raw, uid } = apiSpec || {};
const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {};
const handleLeftPaneWidthChange = useCallback(
(w) => {
if (!uid) return;
dispatch(updateApiSpecPanelLeftPaneWidth({ uid, leftPaneWidth: w }));
},
[dispatch, uid]
);
if (!uid) {
return <div className="p-4 opacity-50">API Spec not found!</div>;
}
@@ -79,6 +88,8 @@ const ApiSpecPanel = () => {
<SpecViewer
content={raw}
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
leftPaneWidth={leftPaneWidth ?? null}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
</StyledWrapper>
);

View File

@@ -31,7 +31,7 @@ const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
/>
</div>
<div className="flex btn-action justify-between items-center mt-3">
<button className="text-link select-none ml-auto" onClick={onToggle}>
<button className="text-link select-none ml-auto" data-testid="key-value-edit-toggle" onClick={onToggle}>
Key/Value Edit
</button>
</div>

View File

@@ -6,7 +6,7 @@
*/
import React, { createRef } from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { debounce, isEqual } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
@@ -17,6 +17,14 @@ import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
import {
applyEditorState,
captureEditorState,
getDocKey,
readPersistedEditorState,
writePersistedEditorState
} from './state-persistence';
import { usePersistenceScope } from 'hooks/usePersistedState/PersistedScopeProvider';
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
@@ -24,7 +32,7 @@ window.JSHINT = JSHINT;
const TAB_SIZE = 2;
export default class CodeEditor extends React.Component {
class CodeEditor extends React.Component {
constructor(props) {
super(props);
@@ -48,8 +56,21 @@ export default class CodeEditor extends React.Component {
};
}
// Thin wrapper around the pure getDocKey helper from state-persistence.js.
// Kept on the class so the rest of the lifecycle code reads naturally.
_getDocKey() {
return getDocKey(this.props);
}
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
const runShortcut = () => {
if (this.props.onRun) {
this.props.onRun();
return;
}
return CodeMirror.Pass;
};
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
@@ -84,8 +105,10 @@ export default class CodeEditor extends React.Component {
this.searchBarRef.current?.focus();
});
},
'Cmd-H': 'replace',
'Ctrl-H': 'replace',
'Cmd-H': this.props.readOnly ? false : 'replace',
'Ctrl-H': this.props.readOnly ? false : 'replace',
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut,
'Tab': function (cm) {
cm.getSelection().includes('\n') || editor.getLine(cm.getCursor().line) == cm.getSelection()
? cm.execCommand('indentMore')
@@ -175,9 +198,49 @@ export default class CodeEditor extends React.Component {
});
if (editor) {
// CM5 was constructed with props.value, so the editor already shows the
// right content. Read this tab's previously persisted view state from
// localStorage and apply it on top — restores folds, cursor, selection,
// undo history, and scroll position.
const docKey = getDocKey(this.props);
this._currentDocKey = docKey;
this.cachedValue = editor.getValue();
applyEditorState(
editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: docKey }),
this.cachedValue
);
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
// Persist view state immediately when the user folds or unfolds — without
// this, a fold only gets saved on the next tab switch / unmount. That
// makes the persistence feel "delayed" or random, especially across
// sub-tab switches that don't change the docKey or unmount the editor.
// Debounced so rapid fold/unfold (e.g. Cmd-Y to fold all) doesn't write
// to localStorage on every event.
this._persistViewStateDebounced = debounce(() => {
if (!this.editor || !this._currentDocKey) return;
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}, 250);
editor.on('fold', this._persistViewStateDebounced);
editor.on('unfold', this._persistViewStateDebounced);
editor.scrollTo(null, this.props.initialScroll);
this._lastScrollTop = this.props.initialScroll || 0;
editor.on('scroll', () => {
const wrapper = editor.getWrapperElement();
if (wrapper && wrapper.offsetParent === null) return;
this._lastScrollTop = editor.getScrollInfo().top;
if (this.props.onScroll && typeof this.props.onScroll === 'function') {
this.props.onScroll(this._lastScrollTop);
}
});
this.addOverlay();
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
@@ -218,11 +281,52 @@ export default class CodeEditor extends React.Component {
this.editor.options.jump.schema = this.props.schema;
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
if (this.editor) {
// Two distinct update paths:
// 1. Doc key changed → tab switch → snapshot outgoing state, load new content, restore incoming state
// 2. Same doc, value changed → external content update → setValue (view state resets)
const newDocKey = getDocKey(this.props);
const docKeyChanged = newDocKey !== this._currentDocKey;
if (docKeyChanged) {
// Path 1 — tab switch.
// Snapshot the outgoing tab's view state to localStorage so a future
// visit can restore it. Then setValue the incoming content and apply
// any view state previously persisted for the incoming tab.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this._currentDocKey = newDocKey;
applyEditorState(
this.editor,
readPersistedEditorState({ scope: this.props.persistenceScope, key: newDocKey }),
this.cachedValue
);
// setValue resets the editor's mode-overlay state — re-apply the
// brunovariables overlay and re-evaluate lint config for the new content.
this.addOverlay();
this.editor.setOption(
'lint',
this.props.mode && this.editor.getValue().trim().length > 0 ? this.lintOptions : false
);
} else if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue) {
// Path 2 — same tab, new external value (e.g. a fresh response arrived
// while this tab was active). Update content; view state resets because
// line positions no longer correspond to anything. Invalidate the
// persisted snapshot too, since the saved cursor/folds/history reflect
// the prior content.
const cursor = this.editor.getCursor();
this.cachedValue = String(this?.props?.value ?? '');
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
writePersistedEditorState({ scope: this.props.persistenceScope, key: this._currentDocKey, state: null });
}
}
if (this.editor) {
@@ -268,12 +372,31 @@ export default class CodeEditor extends React.Component {
componentWillUnmount() {
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this.editor);
this.props.onScroll(this._lastScrollTop);
}
// Snapshot view state to localStorage before tearing down the editor so
// the next mount of a CodeEditor with this docKey can restore folds,
// cursor, selection, undo history, and scroll position.
if (this._currentDocKey) {
writePersistedEditorState({
scope: this.props.persistenceScope,
key: this._currentDocKey,
state: captureEditorState(this.editor)
});
}
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
// Tear down the debounced fold-persistence listener. Cancel any pending
// call so it can't fire after we've already snapshotted state above.
if (this._persistViewStateDebounced) {
this.editor.off('fold', this._persistViewStateDebounced);
this.editor.off('unfold', this._persistViewStateDebounced);
this._persistViewStateDebounced.cancel?.();
}
// Clean up lint error tooltip
this.cleanupLintErrorTooltip?.();
@@ -337,3 +460,12 @@ export default class CodeEditor extends React.Component {
}
};
}
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
const persistenceScope = usePersistenceScope();
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
});
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
export default CodeEditorWithPersistenceScope;

View File

@@ -0,0 +1,129 @@
/*
* CodeEditor view-state persistence — extracted for testability.
*
* Why this exists:
* Every tab switch causes CodeMirror's setValue() to wipe folds, cursor,
* selection, undo history, and scroll position. To preserve them, we serialize
* the relevant pieces to localStorage under a stable key for each editor and
* re-apply them on mount / tab switch. CodeMirror exposes a JSON-serializable
* representation of its undo stack via getHistory()/setHistory(), which is what
* makes Cmd-Z continue working across switches.
*
* Note: we deliberately do NOT persist the content itself — the canonical value
* lives in Redux (props.value). We only persist the editor's "view" state on
* top of that content. If content has drifted between save and restore, fold
* positions are applied leniently (foldCode silently no-ops on invalid lines)
* and history is skipped to avoid an inconsistent undo stack.
*/
export const STORAGE_PREFIX = 'persisted::';
export const DEFAULT_PERSISTENCE_SCOPE = 'global';
export const STORAGE_SEGMENT = 'codeeditor';
export const getScopedStorageKey = (scope, key) => {
const resolvedScope = scope || DEFAULT_PERSISTENCE_SCOPE;
return `${STORAGE_PREFIX}${resolvedScope}::${STORAGE_SEGMENT}::${key}`;
};
// Identifies which Doc state belongs to a given CodeEditor instance.
//
// Callers can pass an explicit `docKey` prop when the auto-derived key would
// collide — e.g. Pre-Request vs Post-Response script editors share the same
// item/mode/readOnly and need an extra disambiguator.
//
// Auto-derived parts:
// id — distinguishes different tabs (requests or collections)
// mode — distinguishes editors within the same tab (e.g. JSON body vs JS script)
// readOnly — distinguishes response viewer (ro) from body editor (rw) when modes match
export const getDocKey = (props) => {
if (props.docKey) return props.docKey;
const id = props.item?.uid || props.collection?.uid || 'default';
const mode = props.mode || 'default';
const readOnly = props.readOnly ? 'ro' : 'rw';
return `${id}:${mode}:${readOnly}`;
};
export const readPersistedEditorState = ({ scope, key }) => {
try {
const raw = localStorage.getItem(getScopedStorageKey(scope, key));
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
};
export const writePersistedEditorState = ({ scope, key, state }) => {
try {
const storageKey = getScopedStorageKey(scope, key);
if (state == null) {
localStorage.removeItem(storageKey);
} else {
localStorage.setItem(storageKey, JSON.stringify(state));
}
} catch {
// localStorage may be unavailable or full (Chromium ~10 MB cap). Editor
// state is non-critical — content lives in Redux — so silently ignore.
}
};
export const captureEditorState = (editor) => {
if (!editor) return null;
const doc = editor.getDoc();
const folds = editor
.getAllMarks()
.filter((m) => m.__isFold)
.map((m) => m.find())
.filter(Boolean)
.map((range) => range.from);
return {
contentLength: doc.getValue().length,
cursor: doc.getCursor(),
selections: doc.listSelections(),
history: doc.getHistory(),
folds,
scrollY: editor.getScrollInfo().top
};
};
export const applyEditorState = (editor, state, currentContent) => {
if (!editor || !state) return;
const doc = editor.getDoc();
const contentMatches = state.contentLength === (currentContent || '').length;
// History/cursor/selection only make sense if content didn't drift — applying
// a stale undo stack to different content would let Cmd-Z replay edits that
// no longer correspond to anything visible.
if (contentMatches) {
if (state.history) {
try { doc.setHistory(state.history); } catch {}
}
if (state.cursor) {
try { doc.setCursor(state.cursor); } catch {}
}
if (state.selections && state.selections.length) {
try { doc.setSelections(state.selections); } catch {}
}
}
// Folds are cheap and lenient — try them either way.
// Sort innermost-first (line desc): when folds are nested, applying the
// inner one before the outer one is safer because brace-fold's findRange
// re-scans the line text. With outer-first, deeply nested arrays inside a
// folded object can fail to refold (issue specific to JSON arrays where
// the helper's lookback can land on the wrong opening character once the
// outer block is collapsed).
if (state.folds && state.folds.length) {
const sorted = [...state.folds].sort(
(a, b) => b.line - a.line || b.ch - a.ch
);
editor.operation(() => {
sorted.forEach((from) => {
try {
editor.foldCode(from, null, 'fold');
} catch {}
});
});
}
if (state.scrollY != null) {
try { editor.scrollTo(null, state.scrollY); } catch {}
}
};

View File

@@ -1,8 +1,10 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
@@ -11,16 +13,27 @@ import StyledWrapper from './StyledWrapper';
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
import Button from 'ui/Button/index';
import ActionIcon from 'ui/ActionIcon/index';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Docs = ({ collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const [isEditing, setIsEditing] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const isEditing = focusedTab?.docsEditing || false;
const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
const preferences = useSelector((state) => state.app.preferences);
// StyledWrapper has overflow-y: auto — use null selector.
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-docs-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
setIsEditing((prev) => !prev);
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
};
const onEdit = (value) => {
@@ -48,7 +61,7 @@ const Docs = ({ collection }) => {
};
return (
<StyledWrapper className="h-full w-full relative flex flex-col">
<StyledWrapper className="h-full w-full relative flex flex-col" ref={wrapperRef}>
<div className="flex flex-row w-full justify-between items-center mb-4">
<div className="text-lg font-medium flex items-center gap-2">
<IconFileText size={20} strokeWidth={1.5} />
@@ -81,9 +94,11 @@ const Docs = ({ collection }) => {
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<div className="h-full overflow-auto pl-1">
<div className="pl-1">
<div className="h-[1px] min-h-[500px]">
{
docs?.length > 0

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -13,6 +13,8 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -25,6 +27,9 @@ const Headers = ({ collection }) => {
? get(collection, 'draft.root.request.headers', [])
: get(collection, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-headers-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
@@ -120,7 +125,7 @@ const Headers = ({ collection }) => {
}
return (
<StyledWrapper className="h-full w-full">
<StyledWrapper className="h-full w-full" ref={wrapperRef}>
<div className="text-xs mb-4 text-muted">
Add request headers that will be sent with every request in this collection.
</div>
@@ -133,9 +138,10 @@ const Headers = ({ collection }) => {
getRowError={getRowError}
columnWidths={collectionHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-headers', widths)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

@@ -8,10 +8,12 @@ const Overview = ({ collection }) => {
return (
<div className="h-full">
<div className="grid grid-cols-5 gap-5 h-full">
<div className="col-span-2">
<div className="text-lg font-medium flex items-center gap-2">
<IconBox size={20} stroke={1.5} />
{collection?.name}
<div className="col-span-2 overflow-clip text-ellipsis">
<div className="flex gap-2 items-center min-w-0">
<IconBox size={20} stroke={1.5} className="flex-shrink-0" />
<span className="overflow-hidden text-lg font-medium whitespace-nowrap text-ellipsis">
{collection?.name}
</span>
</div>
<Info collection={collection} />
<RequestsNotLoaded collection={collection} />

View File

@@ -12,6 +12,7 @@ import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ collection }) => {
const dispatch = useDispatch();
@@ -38,13 +39,20 @@ const Script = ({ collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Refresh CodeMirror when tab becomes visible
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `collection-pre-req-scroll-${collection.uid}`, default: 0 });
const [postResScroll, setPostResScroll] = usePersistedState({ key: `collection-post-res-scroll-${collection.uid}`, default: 0 });
// Refresh CodeMirror when tab becomes visible and restore scroll position.
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
}
}, 0);
@@ -99,10 +107,11 @@ const Script = ({ collection }) => {
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">
<TabsContent value="pre-request" className="mt-2" dataTestId="collection-pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="collection-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
@@ -111,13 +120,16 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
</TabsContent>
<TabsContent value="post-response" className="mt-2">
<TabsContent value="post-response" className="mt-2" dataTestId="collection-post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="collection-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
@@ -126,6 +138,8 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
</TabsContent>
</Tabs>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
@@ -7,13 +7,16 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Tests = ({ collection }) => {
const dispatch = useDispatch();
const testsEditorRef = useRef(null);
const tests = collection.draft?.root ? get(collection, 'draft.root.request.tests', '') : get(collection, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [testsScroll, setTestsScroll] = usePersistedState({ key: `collection-tests-scroll-${collection.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -30,7 +33,9 @@ const Tests = ({ collection }) => {
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="collection-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
@@ -39,6 +44,8 @@ const Tests = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="mt-6">

View File

@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setCollectionVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ collection, vars, varType }) => {
const VarsTable = ({ collection, vars, varType, initialScroll = 0 }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -87,6 +87,7 @@ const VarsTable = ({ collection, vars, varType }) => {
getRowError={getRowError}
columnWidths={collectionVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('collection-vars', widths)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

@@ -1,10 +1,12 @@
import React from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Vars = ({ collection }) => {
const dispatch = useDispatch();
@@ -12,15 +14,19 @@ const Vars = ({ collection }) => {
const responseVars = collection.draft?.root ? get(collection, 'draft.root.request.vars.res', []) : get(collection, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveCollectionSettings(collection.uid));
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `collection-vars-scroll-${collection.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.collection-settings-content', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="w-full flex flex-col">
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<div className="flex-1">
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable collection={collection} vars={requestVars} varType="request" />
<VarsTable collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
</div>
<div className="flex-1">
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable collection={collection} vars={responseVars} varType="response" />
<VarsTable collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -146,7 +146,7 @@ const CollectionSettings = ({ collection }) => {
{protobufConfig.protoFiles && protobufConfig.protoFiles.length > 0 && <StatusDot />}
</div>
</div>
<section className="mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="collection-settings-content mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</StyledWrapper>
);
};

View File

@@ -4,12 +4,14 @@ import find from 'lodash/find';
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Documentation = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -21,6 +23,10 @@ const Documentation = ({ item, collection }) => {
const docs = item.draft ? get(item, 'draft.request.docs') : get(item, 'request.docs');
const preferences = useSelector((state) => state.app.preferences);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-docs-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
};
@@ -42,7 +48,7 @@ const Documentation = ({ item, collection }) => {
}
return (
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative">
<StyledWrapper className="flex flex-col gap-y-1 h-full w-full relative" ref={wrapperRef}>
<div className="editing-mode" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
@@ -57,6 +63,8 @@ const Documentation = ({ item, collection }) => {
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />

View File

@@ -179,6 +179,17 @@ const Wrapper = styled.div`
}
}
.breadcrumb-collapsed-dropdown {
max-width: 250px;
}
.breadcrumb-collapsed-item {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-separator {
height: 1px;
background-color: ${(props) => props.theme.dropdown.separator};

View File

@@ -1,10 +1,9 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
display: block;
width: 100%;
isolation: isolate;
&.is-resizing {
cursor: col-resize !important;
@@ -12,9 +11,9 @@ const StyledWrapper = styled.div`
}
.table-container {
overflow: auto;
border-radius: ${(props) => props.theme.border.radius.base};
border: solid 1px ${(props) => props.theme.border.border0};
overflow: clip;
}
table {
@@ -80,6 +79,8 @@ const StyledWrapper = styled.div`
tbody {
tr {
height: 35px;
max-height: 35px;
transition: background 0.1s ease;
&:last-child td {
@@ -87,6 +88,8 @@ const StyledWrapper = styled.div`
}
td {
height: 35px;
max-height: 35px;
padding: 1px 10px !important;
border-top: none !important;
border-left: none !important;
@@ -96,17 +99,23 @@ const StyledWrapper = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
&:last-child {
border-right: none;
> div:not(.drag-handle) {
height: 33px;
max-height: 33px;
overflow: hidden;
}
/* Handle CodeMirror editors overflow */
.cm-editor {
max-width: 100%;
height: 33px !important;
max-height: 33px !important;
.cm-scroller {
overflow: hidden !important;
max-height: 33px;
}
.cm-content {
@@ -185,12 +194,23 @@ const StyledWrapper = styled.div`
}
.drag-handle {
opacity: 0;
transition: opacity 0.1s ease;
display: flex;
align-items: center;
justify-content: center;
.icon-grip,
.icon-minus {
color: ${(props) => props.theme.colors.text.muted};
}
}
tbody tr:hover .drag-handle,
tbody tr.drag-over .drag-handle {
opacity: 1;
}
select {
background-color: transparent;
color: ${(props) => props.theme.text};

View File

@@ -1,10 +1,49 @@
import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import { IconTrash, IconAlertCircle, IconGripVertical, IconMinusVertical } from '@tabler/icons';
import { Tooltip } from 'react-tooltip';
import { uuid } from 'utils/common';
import StyledWrapper from './StyledWrapper';
const MIN_COLUMN_WIDTH = 80;
const ROW_HEIGHT = 35;
const findScrollParent = (element) => {
let parent = element?.parentElement;
while (parent) {
const { overflowY } = getComputedStyle(parent);
if (overflowY === 'auto' || overflowY === 'scroll') return parent;
parent = parent.parentElement;
}
return null;
};
const TableRow = React.memo(
({ children, item, context, ...rest }) => {
const rowIndex = Number(rest['data-item-index']);
const { reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, onDragStart, onDragOver, onDrop, onDragEnd, onDragLeave } = context;
const isEmpty = isLastEmptyRow(item, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
const isDragOver = canDrag && dragOverRow === rowIndex;
const existingClass = rest.className || '';
const className = isDragOver ? `${existingClass} drag-over`.trim() : existingClass;
return (
<tr
{...rest}
className={className}
draggable={canDrag}
onDragStart={canDrag ? (e) => onDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => onDragOver(e, rowIndex) : undefined}
onDragLeave={canDrag ? (e) => onDragLeave(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => onDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? onDragEnd : undefined}
>
{children}
</tr>
);
}
);
const EditableTable = ({
tableId, // Not being used kept to maintain uniqueness & pass similar in onColumnWidthsChange
@@ -23,15 +62,27 @@ const EditableTable = ({
showAddRow = true,
testId = 'editable-table',
columnWidths,
initialScroll = 0,
onColumnWidthsChange
}) => {
const tableRef = useRef(null);
const wrapperRef = useRef(null);
const virtuosoRef = useRef(null);
const emptyRowUidRef = useRef(null);
const [hoveredRow, setHoveredRow] = useState(null);
const prevRowCountRef = useRef(0);
const [resizing, setResizing] = useState(null);
const [tableHeight, setTableHeight] = useState(0);
const [scrollParent, setScrollParent] = useState(null);
const [dragOverRow, setDragOverRow] = useState(null);
const widths = columnWidths || {};
useLayoutEffect(() => {
setScrollParent(findScrollParent(wrapperRef.current));
}, []);
const handleTotalHeightChanged = useCallback((h) => {
setTableHeight(h);
}, []);
const handleColumnWidthsChange = useCallback((newWidths) => {
onColumnWidthsChange?.(newWidths);
}, [onColumnWidthsChange]);
@@ -71,7 +122,7 @@ const EditableTable = ({
const handleMouseUp = () => {
// Convert pixel widths to percentages for responsive scaling
const table = tableRef.current?.querySelector('table');
const table = wrapperRef.current?.querySelector('table');
if (table) {
const tableWidth = table.offsetWidth;
const headerCells = table.querySelectorAll('thead td');
@@ -103,23 +154,6 @@ const EditableTable = ({
document.addEventListener('mouseup', handleMouseUp);
}, [columns, showCheckbox, widths, handleColumnWidthsChange]);
// Track table height for resize handles
useEffect(() => {
const table = tableRef.current?.querySelector('table');
if (!table) return;
const updateHeight = () => {
setTableHeight(table.offsetHeight);
};
updateHeight();
const resizeObserver = new ResizeObserver(updateHeight);
resizeObserver.observe(table);
return () => resizeObserver.disconnect();
}, [rows.length]);
const getColumnWidth = useCallback((column) => {
return widths[column.key] || column.width || 'auto';
}, [widths]);
@@ -179,6 +213,16 @@ const EditableTable = ({
return index === rowsWithEmpty.length - 1 && isEmptyRow(row);
}, [rowsWithEmpty.length, isEmptyRow, showAddRow]);
useEffect(() => {
if (rowsWithEmpty.length > prevRowCountRef.current && prevRowCountRef.current > 0) {
virtuosoRef.current?.scrollToIndex({
index: rowsWithEmpty.length - 1,
behavior: 'smooth'
});
}
prevRowCountRef.current = rowsWithEmpty.length;
}, [rowsWithEmpty.length]);
const handleValueChange = useCallback((rowUid, key, value) => {
const rowIndex = rowsWithEmpty.findIndex((r) => r.uid === rowUid);
if (rowIndex === -1) return;
@@ -245,28 +289,31 @@ const EditableTable = ({
const handleDragOver = useCallback((e, index) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setHoveredRow(index);
setDragOverRow((prev) => (prev === index ? prev : index));
}, []);
const handleDragLeave = useCallback((e, index) => {
if (e.currentTarget.contains(e.relatedTarget)) return;
setDragOverRow((prev) => (prev === index ? null : prev));
}, []);
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
const handleDrop = useCallback((e, toIndex) => {
e.preventDefault();
setDragOverRow(null);
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'), 10);
if (fromIndex !== toIndex && onReorder) {
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) {
setHoveredRow(null);
return;
}
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}
setHoveredRow(null);
if (fromIndex === toIndex || !onReorder) return;
const reorderableRows = showAddRow ? rowsWithEmpty.slice(0, -1) : rowsWithEmpty;
const updatedOrder = [...reorderableRows];
const [movedRow] = updatedOrder.splice(fromIndex, 1);
if (!movedRow) return;
updatedOrder.splice(toIndex, 0, movedRow);
onReorder({ updateReorderedItem: updatedOrder.map((row) => row.uid) });
}, [onReorder, rowsWithEmpty, showAddRow]);
const handleDragEnd = useCallback(() => {
setHoveredRow(null);
setDragOverRow(null);
}, []);
const renderCell = useCallback((column, row, rowIndex) => {
@@ -323,109 +370,124 @@ const EditableTable = ({
);
}, [isLastEmptyRow, getRowError, handleValueChange]);
const reorderableRowCount = showAddRow ? rowsWithEmpty.length - 1 : rowsWithEmpty.length;
const virtuosoContext = useMemo(() => ({
reorderable,
reorderableRowCount,
isLastEmptyRow,
dragOverRow,
onDragStart: handleDragStart,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
onDragEnd: handleDragEnd
}), [reorderable, reorderableRowCount, isLastEmptyRow, dragOverRow, handleDragStart, handleDragOver, handleDragLeave, handleDrop, handleDragEnd]);
const fixedHeaderContent = useCallback(() => (
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
), [showCheckbox, checkboxLabel, columns, getColumnWidth, resizing, tableHeight, handleResizeStart, showDelete]);
const itemContent = useCallback((rowIndex, row) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<>
{showCheckbox && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</>
);
}, [showCheckbox, reorderable, reorderableRowCount, isLastEmptyRow, checkboxKey, disableCheckbox, handleCheckboxChange, columns, renderCell, showDelete, handleRemoveRow]);
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(initialScroll / ROW_HEIGHT))).current;
return (
<StyledWrapper className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}>
<div className="table-container" ref={tableRef} data-testid={testId}>
<table>
<thead>
<tr>
{showCheckbox && (
<td className="text-center">{checkboxLabel}</td>
)}
{columns.map((column, colIndex) => (
<td
key={column.key}
style={{ width: getColumnWidth(column) }}
>
<span className="column-name">{column.name}</span>
{colIndex < columns.length - 1 && (
<div
className={`resize-handle ${resizing === column.key ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, column.key)}
/>
)}
</td>
))}
{showDelete && (
<td style={{ width: '60px' }}></td>
)}
</tr>
</thead>
<tbody>
{rowsWithEmpty.map((row, rowIndex) => {
const isEmpty = isLastEmptyRow(row, rowIndex);
const canDrag = reorderable && !isEmpty && rowIndex < reorderableRowCount;
return (
<tr
key={row.uid}
draggable={canDrag}
onDragStart={canDrag ? (e) => handleDragStart(e, rowIndex) : undefined}
onDragOver={canDrag ? (e) => handleDragOver(e, rowIndex) : undefined}
onDrop={canDrag ? (e) => handleDrop(e, rowIndex) : undefined}
onDragEnd={canDrag ? handleDragEnd : undefined}
onMouseEnter={() => setHoveredRow(rowIndex)}
onMouseLeave={() => setHoveredRow(null)}
>
{showCheckbox && (
<td className="text-center relative">
{reorderable && canDrag && (
<div
draggable
className="drag-handle group absolute z-10 left-[-8px] top-1/2 -translate-y-1/2 p-1 cursor-grab"
>
{hoveredRow === rowIndex && (
<>
<IconGripVertical
size={14}
className="icon-grip hidden group-hover:block"
/>
<IconMinusVertical
size={14}
className="icon-minus block group-hover:hidden"
/>
</>
)}
</div>
)}
{!isEmpty && (
<input
type="checkbox"
className="mousetrap"
data-testid="column-checkbox"
checked={row[checkboxKey] ?? true}
disabled={disableCheckbox}
onChange={(e) => handleCheckboxChange(row.uid, e.target.checked)}
/>
)}
</td>
)}
{columns.map((column) => (
<td key={column.key} data-testid={`column-${column.key}`}>
{renderCell(column, row, rowIndex)}
</td>
))}
{showDelete && (
<td>
{!isEmpty && (
<button
data-testid="column-delete"
onClick={() => handleRemoveRow(row.uid)}
>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
<StyledWrapper
ref={wrapperRef}
data-testid={testId}
className={`${showCheckbox ? 'has-checkbox' : 'no-checkbox'} ${resizing ? 'is-resizing' : ''}`}
>
{scrollParent && (
<TableVirtuoso
ref={virtuosoRef}
className="table-container"
customScrollParent={scrollParent}
data={rowsWithEmpty}
components={{ TableRow }}
context={virtuosoContext}
defaultItemHeight={ROW_HEIGHT}
initialTopMostItemIndex={initialTopMostItemIndex}
totalListHeightChanged={handleTotalHeightChanged}
computeItemKey={(_, item) => item.uid}
fixedHeaderContent={fixedHeaderContent}
itemContent={itemContent}
/>
)}
</StyledWrapper>
);
};

View File

@@ -15,6 +15,7 @@ const Wrapper = styled.div`
overflow-y: auto;
border-radius: 8px;
border: solid 1px ${(props) => props.theme.border.border0};
transition: height 75ms cubic-bezier(0,1.12,.84,.64);
}
table {

View File

@@ -15,13 +15,16 @@ import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import { stripEnvVarUid } from 'utils/environments';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
const MIN_ROW_HEIGHT = 35;
const TableRow = React.memo(
({ children, item }) => (
<tr key={item.uid} data-testid={`env-var-row-${item.name}`}>
({ children, item, style, ...rest }) => (
<tr key={item.uid} style={style} {...rest} data-testid={`env-var-row-${item?.name}`}>
{children}
</tr>
),
@@ -56,7 +59,19 @@ const EnvironmentVariablesTable = ({
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
const [tableHeight, setTableHeight] = useState(MIN_H);
const rowCount = (environment.variables?.length || 0) + 1;
const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT);
// We need to add <EditableTable/> component for env table
const [scroll, setScroll] = usePersistedState({
key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`,
default: 0
});
const scrollerRef = useRef(null);
const [scrollerEl, setScrollerEl] = useState(null);
scrollerRef.current = scrollerEl;
const initialTopMostItemIndex = useRef(Math.max(0, Math.floor(scroll / MIN_ROW_HEIGHT))).current;
useTrackScroll({ ref: scrollerRef, onChange: setScroll, initialValue: scroll, enabled: !!scrollerEl });
// Use environment UID as part of tableId so each environment has its own column widths
const tableId = `env-vars-table-${environment.uid}`;
@@ -483,6 +498,9 @@ const EnvironmentVariablesTable = ({
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
scrollerRef={setScrollerEl}
initialTopMostItemIndex={initialTopMostItemIndex}
overscan={Math.min(30, filteredVariables.length)}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
@@ -502,7 +520,6 @@ const EnvironmentVariablesTable = ({
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
@@ -535,7 +552,7 @@ const EnvironmentVariablesTable = ({
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
placeholder={!variable.name || (typeof variable.name === 'string' && variable.name.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onFocus={() => handleRowFocus(variable.uid)}
onBlur={() => {
@@ -560,7 +577,7 @@ const EnvironmentVariablesTable = ({
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
placeholder={variable.value == null || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => {
@@ -570,6 +587,19 @@ const EnvironmentVariablesTable = ({
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
// Append a new empty row when editing value on the last row
if (isLastRow) {
setTimeout(() => {
formik.setFieldValue(formik.values.length, {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}, false);
}, 0);
}
}}
onSave={handleSave}
/>
@@ -610,6 +640,8 @@ const EnvironmentVariablesTable = ({
/>
)}
{/* We should re-think of these buttons placement in component as we use TableVirtuoso which because of
these buttons renders at some transition: height 0.1s ease` */}
<div className="button-container">
<div className="flex items-center">
<button type="button" className="submit" onClick={handleSave} data-testid="save-env">

View File

@@ -17,7 +17,11 @@ const EnvironmentListContent = ({
{environments && environments.length > 0 ? (
<>
<div className="environment-list">
<div className="dropdown-item no-environment" onClick={() => onEnvironmentSelect(null)}>
<div
className={`dropdown-item no-environment ${!activeEnvironmentUid ? 'dropdown-item-active' : ''}`}
onClick={() => onEnvironmentSelect(null)}
>
<span className="w-2 shrink-0" />
<span>No Environment</span>
</div>
<ToolHint

View File

@@ -117,6 +117,10 @@ const Wrapper = styled.div`
overflow: hidden;
}
.no-environment {
color: ${(props) => props.theme.colors.text.subtext0};
}
.environment-list {
flex: 1;
overflow-y: auto;

View File

@@ -2,7 +2,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
height: 100%;
overflow-y: auto;
position: relative;
.editing-mode {

View File

@@ -1,24 +1,35 @@
import 'github-markdown-css/github-markdown.css';
import get from 'lodash/get';
import find from 'lodash/find';
import { updateFolderDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useState } from 'react';
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Documentation = ({ collection, folder }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [isEditing, setIsEditing] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const isEditing = focusedTab?.docsEditing || false;
const docs = folder.draft ? get(folder, 'draft.docs', '') : get(folder, 'root.docs', '');
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-docs-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, enabled: !isEditing, initialValue: scroll });
const toggleViewMode = () => {
setIsEditing((prev) => !prev);
dispatch(updateDocsEditing({ uid: activeTabUid, docsEditing: !isEditing }));
};
const onEdit = (value) => {
@@ -38,7 +49,7 @@ const Documentation = ({ collection, folder }) => {
}
return (
<StyledWrapper className="w-full relative flex flex-col">
<StyledWrapper className="w-full relative flex flex-col" ref={wrapperRef}>
<div className="editing-mode flex justify-between items-center flex-shrink-0" role="tab" onClick={toggleViewMode}>
{isEditing ? 'Preview' : 'Edit'}
</div>
@@ -55,6 +66,8 @@ const Documentation = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
</div>
<div className="mt-6 flex-shrink-0">

View File

@@ -1,6 +1,6 @@
import styled from 'styled-components';
const Wrapper = styled.div`
const StyledWrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
@@ -53,4 +53,4 @@ const Wrapper = styled.div`
}
`;
export default Wrapper;
export default StyledWrapper;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -13,6 +13,8 @@ import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from 'components/BulkEditor/index';
import Button from 'ui/Button';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -25,6 +27,9 @@ const Headers = ({ collection, folder }) => {
? get(folder, 'draft.request.headers', [])
: get(folder, 'root.request.headers', []);
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-headers-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
@@ -125,7 +130,7 @@ const Headers = ({ collection, folder }) => {
}
return (
<StyledWrapper className="w-full">
<StyledWrapper className="w-full" ref={wrapperRef}>
<div className="text-xs mb-4 text-muted">
Request headers that will be sent with every request inside this folder.
</div>
@@ -138,9 +143,10 @@ const Headers = ({ collection, folder }) => {
getRowError={getRowError}
columnWidths={folderHeadersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-headers', widths)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<button className="text-link select-none" onClick={toggleBulkEditMode}>
<button className="text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

@@ -12,6 +12,7 @@ import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -39,13 +40,20 @@ const Script = ({ collection, folder }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Refresh CodeMirror when tab becomes visible
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `folder-pre-req-scroll-${folder.uid}`, default: 0 });
const [postResScroll, setPostResScroll] = usePersistedState({ key: `folder-post-res-scroll-${folder.uid}`, default: 0 });
// Refresh CodeMirror when tab becomes visible and restore scroll position.
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
// (TabsContent hides inactive tabs via display:none). After refresh() recalculates layout, we re-apply scrollTo().
useEffect(() => {
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
}
}, 0);
@@ -102,10 +110,11 @@ const Script = ({ collection, folder }) => {
</TabsTrigger>
</TabsList>
<TabsContent value="pre-request" className="mt-2">
<TabsContent value="pre-request" className="mt-2" dataTestId="folder-pre-request-script-editor">
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="folder-script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
onEdit={onRequestScriptEdit}
@@ -114,13 +123,16 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
</TabsContent>
<TabsContent value="post-response" className="mt-2">
<TabsContent value="post-response" className="mt-2" dataTestId="folder-post-response-script-editor">
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="folder-script:post-response"
value={responseScript || ''}
theme={displayedTheme}
onEdit={onResponseScriptEdit}
@@ -129,6 +141,8 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
</TabsContent>
</Tabs>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
@@ -7,13 +7,16 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
const Tests = ({ collection, folder }) => {
const dispatch = useDispatch();
const testsEditorRef = useRef(null);
const tests = folder.draft ? get(folder, 'draft.request.tests', '') : get(folder, 'root.request.tests', '');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [testsScroll, setTestsScroll] = usePersistedState({ key: `folder-tests-scroll-${folder.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -31,7 +34,9 @@ const Tests = ({ collection, folder }) => {
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="folder-tests"
value={tests || ''}
theme={displayedTheme}
onEdit={onEdit}
@@ -40,6 +45,8 @@ const Tests = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<div className="mt-6">

View File

@@ -11,7 +11,7 @@ import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
import { setFolderVars } from 'providers/ReduxStore/slices/collections/index';
const VarsTable = ({ folder, collection, vars, varType }) => {
const VarsTable = ({ folder, collection, vars, varType, initialScroll = 0 }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -93,6 +93,7 @@ const VarsTable = ({ folder, collection, vars, varType }) => {
getRowError={getRowError}
columnWidths={folderVarsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('folder-vars', widths)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

@@ -1,10 +1,12 @@
import React from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import Button from 'ui/Button';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Vars = ({ collection, folder }) => {
const dispatch = useDispatch();
@@ -12,15 +14,19 @@ const Vars = ({ collection, folder }) => {
const responseVars = folder.draft ? get(folder, 'draft.request.vars.res', []) : get(folder, 'root.request.vars.res', []);
const handleSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `folder-vars-scroll-${folder.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.folder-settings-content', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="w-full flex flex-col">
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<div>
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" />
<VarsTable folder={folder} collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
</div>
<div>
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" />
<VarsTable folder={folder} collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
</div>
<div className="mt-6">
<Button type="submit" size="sm" onClick={handleSave}>

View File

@@ -101,7 +101,7 @@ const FolderSettings = ({ collection, folder }) => {
Docs
</div>
</div>
<section className="flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
<section className="folder-settings-content flex mt-4 h-full overflow-auto">{getTabPanel(tab)}</section>
</div>
</StyledWrapper>
);

View File

@@ -267,14 +267,16 @@ const GlobalSearchModal = ({ isOpen, onClose }) => {
uid: result.item.uid,
collectionUid: result.collectionUid,
requestPaneTab: getDefaultRequestPaneTab(result.item),
type: 'request'
type: result.item.type,
pathname: result.item.pathname
}));
}
} else if (result.type === SEARCH_TYPES.FOLDER) {
dispatch(addTab({
uid: result.item.uid,
collectionUid: result.collectionUid,
type: 'folder-settings'
type: 'folder-settings',
pathname: result.item.pathname
}));
} else if (result.type === SEARCH_TYPES.COLLECTION) {
dispatch(addTab({

View File

@@ -208,21 +208,35 @@ const Wrapper = styled.div`
outline-offset: 2px;
}
&:checked {
&:checked,
&:indeterminate {
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);
}
&:checked::after,
&:indeterminate::after {
content: '';
position: absolute;
}
&:checked::after {
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);
}
&:indeterminate::after {
left: 2px;
top: 6px;
width: 10px;
height: 2px;
background: ${(props) => props.theme.button2.color.primary.text};
border-radius: 2px;
}
}
`;

View File

@@ -30,6 +30,13 @@ class MultiLineEditor extends Component {
// Initialize CodeMirror as a single line editor
/** @type {import("codemirror").Editor} */
const variables = getAllVariables(this.props.collection, this.props.item);
const runShortcut = () => {
if (this.props.onRun) {
this.props.onRun();
return;
}
return CodeMirror.Pass;
};
this.editor = CodeMirror(this.editorRef.current, {
lineWrapping: false,
@@ -47,6 +54,8 @@ class MultiLineEditor extends Component {
extraKeys: {
'Cmd-F': () => {},
'Ctrl-F': () => {},
'Cmd-Enter': runShortcut,
'Ctrl-Enter': runShortcut,
// Tabbing disabled to make tabindex work
'Tab': false,
'Shift-Tab': false

View File

@@ -1,8 +1,11 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import find from 'lodash/find';
import { IconLoader2, IconCloud } from '@tabler/icons';
import fastJsonFormat from 'fast-json-format';
import SpecViewer from 'components/ApiSpecPanel/SpecViewer';
import StyledWrapper from 'components/ApiSpecPanel/StyledWrapper';
import { updateApiSpecTabLeftPaneWidth } from 'providers/ReduxStore/slices/tabs';
/**
* Pretty-print JSON content for readable display. YAML content is returned as-is.
@@ -17,7 +20,17 @@ const prettyPrintSpec = (content) => {
}
};
const OpenAPISpecTab = ({ collection }) => {
const OpenAPISpecTab = ({ collection, tabUid }) => {
const dispatch = useDispatch();
const leftPaneWidth = useSelector((state) => {
const tab = find(state.tabs.tabs, (t) => t.uid === tabUid);
return tab?.apiSpecLeftPaneWidth ?? null;
});
const handleLeftPaneWidthChange = useCallback(
(w) => dispatch(updateApiSpecTabLeftPaneWidth({ uid: tabUid, apiSpecLeftPaneWidth: w })),
[dispatch, tabUid]
);
const [specContent, setSpecContent] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
@@ -26,6 +39,16 @@ const OpenAPISpecTab = ({ collection }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const sourceUrl = openApiSyncConfig?.sourceUrl;
// Latest env context for loadSpec's remote-fetch fallback. Kept out of
// loadSpec's deps so toggling a variable doesn't refire the spec load.
const envContextRef = useRef({});
envContextRef.current = {
activeEnvironmentUid: collection?.activeEnvironmentUid,
environments: collection?.environments,
runtimeVariables: collection?.runtimeVariables,
globalEnvironmentVariables: collection?.globalEnvironmentVariables
};
const loadSpec = useCallback(async () => {
setIsLoading(true);
setError(null);
@@ -42,12 +65,7 @@ const OpenAPISpecTab = ({ collection }) => {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl,
environmentContext: {
activeEnvironmentUid: collection.activeEnvironmentUid,
environments: collection.environments,
runtimeVariables: collection.runtimeVariables,
globalEnvironmentVariables: collection.globalEnvironmentVariables
}
environmentContext: envContextRef.current
});
if (fetchResult.content) {
setSpecContent(prettyPrintSpec(fetchResult.content));
@@ -64,7 +82,7 @@ const OpenAPISpecTab = ({ collection }) => {
} finally {
setIsLoading(false);
}
}, [collection?.pathname, collection?.uid, collection?.activeEnvironmentUid, collection?.environments, collection?.runtimeVariables, collection?.globalEnvironmentVariables, sourceUrl]);
}, [collection?.pathname, collection?.uid, sourceUrl]);
useEffect(() => {
if (collection?.pathname) {
@@ -97,7 +115,12 @@ const OpenAPISpecTab = ({ collection }) => {
<span>Showing spec file from {sourceUrl}.</span>
</div>
)}
<SpecViewer content={specContent} readOnly />
<SpecViewer
content={specContent}
readOnly
leftPaneWidth={leftPaneWidth}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
/>
</StyledWrapper>
);
};

View File

@@ -1,6 +1,5 @@
import { useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { selectStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
IconCopy,
IconDotsVertical,
@@ -37,7 +36,7 @@ const OpenAPISyncHeader = ({
}
}, [sourceUrl, sourceIsLocal, collection.pathname]);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
const title = specMeta?.title || spec?.info?.title || 'Unknown API';
const copyUrl = async () => {

View File

@@ -1,6 +1,5 @@
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';
@@ -43,7 +42,7 @@ const OverviewSection = ({ collection, storedSpec, collectionDrift, specDrift, r
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
const reduxError = useSelector((state) => state.openapiSync?.collectionUpdates?.[collection.uid]?.error);
const specMeta = useSelector(selectStoredSpecMeta(collection.uid));
const specMeta = useSelector((state) => state.openapiSync?.storedSpecMeta?.[collection.uid] || null);
const activeError = error || reduxError;
const version = specMeta?.version;

View File

@@ -0,0 +1,46 @@
import React from 'react';
/**
* One virtualized row in the spec diff. Renders the side-by-side cells
* (left line number, left code, right line number, right code) for a normal
* row, or a single full-width cell for a hunk header.
*
* Paired del+ins rows render via dangerouslySetInnerHTML so the <del>/<ins>
* markup from the word-level diff cache shows through. Solo rows render as
* React text children and let React handle escaping.
*/
const DiffRow = ({ row, active, cache }) => {
if (!row) return null; // guard: Virtuoso race on rapid open/close or theme switch
if (row.leftKind === 'hunk') {
return (
<div className="diff-row diff-row-hunk">
<div className="diff-cell-hunk">{row.leftText}</div>
</div>
);
}
const isChange = row.leftKind === 'del' && row.rightKind === 'ins';
const wd = isChange ? cache.getWordDiff(row.leftText, row.rightText) : null;
const renderContent = (text, html) =>
html !== null
? <span className="diff-content" dangerouslySetInnerHTML={{ __html: html }} />
: <span className="diff-content">{text}</span>;
return (
<div className={`diff-row ${active ? 'diff-row-focused' : ''}`}>
<div className={`diff-cell-num diff-kind-${row.leftKind}`}>{row.leftNum ?? ''}</div>
<div className={`diff-cell-code diff-kind-${row.leftKind}`}>
<span className="diff-prefix">{row.leftKind === 'del' ? '-' : ' '}</span>
{renderContent(row.leftText, wd ? wd.left : null)}
</div>
<div className={`diff-cell-num diff-kind-${row.rightKind}`}>{row.rightNum ?? ''}</div>
<div className={`diff-cell-code diff-kind-${row.rightKind}`}>
<span className="diff-prefix">{row.rightKind === 'ins' ? '+' : ' '}</span>
{renderContent(row.rightText, wd ? wd.right : null)}
</div>
</div>
);
};
export default React.memo(DiffRow);

View File

@@ -0,0 +1,160 @@
import { buildRows, wrapIndex } from '../buildRows';
// Helpers to construct fixture "parsed" data in the shape Diff2Html.parse()
// actually returns. Line types come from the LineType enum
// ('context' | 'insert' | 'delete'), NOT the CSSLineClass enum
// ('d2h-cntx' | 'd2h-ins' | 'd2h-del'). Verified at
// packages/bruno-app/public/static/diff2Html.js:3172.
const ctx = (text, oldNum, newNum) => ({
type: 'context',
content: ` ${text}`,
oldNumber: oldNum,
newNumber: newNum
});
const del = (text, oldNum) => ({ type: 'delete', content: `-${text}`, oldNumber: oldNum });
const ins = (text, newNum) => ({ type: 'insert', content: `+${text}`, newNumber: newNum });
const block = (header, lines) => ({ header, lines });
const file = (...blocks) => [{ blocks }];
describe('buildRows', () => {
test('1. empty/missing input → empty rows and changeBlocks', () => {
expect(buildRows(null)).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows(undefined)).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows([])).toEqual({ rows: [], changeBlocks: [] });
expect(buildRows([{ blocks: [] }])).toEqual({ rows: [], changeBlocks: [] });
});
test('2. all-context hunk → 0 change blocks, only ctx + hunk rows', () => {
const parsed = file(block('@@ -1,3 +1,3 @@', [ctx('a', 1, 1), ctx('b', 2, 2), ctx('c', 3, 3)]));
const { rows, changeBlocks } = buildRows(parsed);
expect(changeBlocks).toEqual([]);
expect(rows).toHaveLength(4); // 1 hunk + 3 ctx
expect(rows[0].leftKind).toBe('hunk');
expect(rows[1].leftKind).toBe('ctx');
expect(rows[1].leftText).toBe('a');
expect(rows[1].rightText).toBe('a');
expect(rows[1].leftNum).toBe(1);
expect(rows[1].rightNum).toBe(1);
});
test('3. pure-deletion run → del rows with empty placeholders on right', () => {
const parsed = file(
block('@@ -1,3 +1,1 @@', [ctx('keep', 1, 1), del('gone1', 2), del('gone2', 3)])
);
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(4); // 1 hunk + 1 ctx + 2 del rows
expect(rows[2].leftKind).toBe('del');
expect(rows[2].rightKind).toBe('empty');
expect(rows[2].leftText).toBe('gone1');
expect(rows[2].rightText).toBe('');
expect(rows[2].leftNum).toBe(2);
expect(rows[2].rightNum).toBeNull();
expect(rows[3].leftKind).toBe('del');
expect(rows[3].leftText).toBe('gone2');
// Two consecutive deletions form one block
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
});
test('4. pure-insertion run → empty placeholders on left, ins on right', () => {
const parsed = file(
block('@@ -1,1 +1,3 @@', [ctx('keep', 1, 1), ins('new1', 2), ins('new2', 3)])
);
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(4);
expect(rows[2].leftKind).toBe('empty');
expect(rows[2].rightKind).toBe('ins');
expect(rows[2].leftText).toBe('');
expect(rows[2].rightText).toBe('new1');
expect(rows[2].leftNum).toBeNull();
expect(rows[2].rightNum).toBe(2);
expect(changeBlocks).toEqual([{ startIdx: 2, endIdx: 3 }]);
});
test('matched del+ins pair → paired row with leftKind=del, rightKind=ins', () => {
const parsed = file(block('@@ -1,1 +1,1 @@', [del('old', 1), ins('new', 1)]));
const { rows, changeBlocks } = buildRows(parsed);
expect(rows).toHaveLength(2); // hunk + 1 paired change row
// Paired row wears natural del/ins kinds — DiffRow detects this combo
// to run word-level diff. Matches GitHub's side-by-side convention
// (red left = deleted content, green right = inserted content).
expect(rows[1].leftKind).toBe('del');
expect(rows[1].rightKind).toBe('ins');
expect(rows[1].leftText).toBe('old');
expect(rows[1].rightText).toBe('new');
expect(rows[1].leftNum).toBe(1);
expect(rows[1].rightNum).toBe(1);
expect(changeBlocks).toEqual([{ startIdx: 1, endIdx: 1 }]);
});
test('5. multi-hunk diff → hunk rows insert correctly + blocks segment per change region', () => {
const parsed = file(
block('@@ -1,2 +1,2 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2)]),
block('@@ -10,2 +10,2 @@', [ctx('x', 10, 10), del('y', 11), ins('Y', 11)])
);
const { rows, changeBlocks } = buildRows(parsed);
// Block 1: hunk + ctx + 1 paired change = 3 rows
// Block 2: hunk + ctx + 1 paired change = 3 rows
expect(rows).toHaveLength(6);
expect(rows[0].leftKind).toBe('hunk');
expect(rows[3].leftKind).toBe('hunk');
// Two distinct change blocks (separated by hunk header reset)
expect(changeBlocks).toEqual([
{ startIdx: 2, endIdx: 2 },
{ startIdx: 5, endIdx: 5 }
]);
});
test('6. REGRESSION: change-block count matches expected counts for 3 fixture shapes', () => {
// The old DOM walker counted contiguous DOM rows containing
// .d2h-ins/.d2h-del/.d2h-change as one block. The new row-list walker
// must produce the same count for the same diff shape.
// Fixture A: small diff, one contiguous change region
const fixtureA = file(
block('@@ -1,4 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ctx('c', 3, 3)])
);
expect(buildRows(fixtureA).changeBlocks).toHaveLength(1);
// Fixture B: medium, two separate change regions in one hunk
const fixtureB = file(
block('@@ -1,7 +1,7 @@', [
ctx('a', 1, 1),
del('b', 2),
ins('B', 2),
ctx('c', 3, 3),
ctx('d', 4, 4),
del('e', 5),
ins('E', 5),
ctx('f', 6, 6)
])
);
expect(buildRows(fixtureB).changeBlocks).toHaveLength(2);
// Fixture C: multi-hunk with adjacent del+ins runs that form a single
// contiguous change region per hunk
const fixtureC = file(
block('@@ -1,3 +1,4 @@', [ctx('a', 1, 1), del('b', 2), ins('B', 2), ins('C', 3)]),
block('@@ -10,4 +11,4 @@', [
ctx('x', 10, 11),
del('y', 11),
del('z', 12),
ins('Y', 12),
ins('Z', 13)
])
);
expect(buildRows(fixtureC).changeBlocks).toHaveLength(2);
});
});
describe('wrapIndex', () => {
test('7. wrap-around modulo handles negative and overflow', () => {
expect(wrapIndex(0, 5)).toBe(0);
expect(wrapIndex(4, 5)).toBe(4);
expect(wrapIndex(5, 5)).toBe(0);
expect(wrapIndex(6, 5)).toBe(1);
expect(wrapIndex(-1, 5)).toBe(4);
expect(wrapIndex(-6, 5)).toBe(4);
expect(wrapIndex(0, 0)).toBe(0);
expect(wrapIndex(3, 0)).toBe(0);
});
});

View File

@@ -0,0 +1,164 @@
/**
* Flatten Diff2Html's parsed unified-diff output into what the virtualized
* renderer needs:
*
* rows[] — one entry per visual row in the side-by-side layout
* (exactly what Virtuoso renders)
* changeBlocks[] — index ranges into rows[], drives Next/Prev navigation
*
* Row shape:
* { leftNum, leftText, leftKind, rightNum, rightText, rightKind }
* *Kind ∈ 'ctx' | 'del' | 'ins' | 'empty' | 'hunk'
*
* When a row has leftKind='del' AND rightKind='ins', DiffRow recognises it
* as a matched change and renders word-level highlights.
*/
// Diff2Html's parse() leaves the leading '+' / '-' / ' ' on each line's
// content. DiffRow renders that marker in its own styled span, so we strip
// it from the displayed text.
const stripLeadingMarker = (content) => (content || '').replace(/^[+\- ]/, '');
// Row factories — keep the row object shape consistent in one place.
const hunkRow = (header) => ({
leftKind: 'hunk',
rightKind: 'hunk',
leftText: header,
rightText: header,
leftNum: null,
rightNum: null
});
const contextRow = (line) => ({
leftKind: 'ctx',
rightKind: 'ctx',
leftText: stripLeadingMarker(line.content),
rightText: stripLeadingMarker(line.content),
leftNum: line.oldNumber ?? null,
rightNum: line.newNumber ?? null
});
const pairedChangeRow = (deletion, insertion) => ({
leftKind: 'del',
rightKind: 'ins',
leftText: stripLeadingMarker(deletion.content),
rightText: stripLeadingMarker(insertion.content),
leftNum: deletion.oldNumber ?? null,
rightNum: insertion.newNumber ?? null
});
const soloDeletionRow = (deletion) => ({
leftKind: 'del',
rightKind: 'empty',
leftText: stripLeadingMarker(deletion.content),
rightText: '',
leftNum: deletion.oldNumber ?? null,
rightNum: null
});
const soloInsertionRow = (insertion) => ({
leftKind: 'empty',
rightKind: 'ins',
leftText: '',
rightText: stripLeadingMarker(insertion.content),
leftNum: null,
rightNum: insertion.newNumber ?? null
});
export function buildRows(parsed) {
const rows = [];
if (!parsed || !Array.isArray(parsed) || parsed.length === 0) {
return { rows, changeBlocks: [] };
}
// Spec sync always produces a single-file diff; ignore any others.
const hunks = parsed[0]?.blocks || [];
// ── Pass 1: flatten each hunk's lines into visual rows ──
for (const hunk of hunks) {
if (hunk.header) rows.push(hunkRow(hunk.header));
const lines = hunk.lines || [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (line.type === 'context') {
rows.push(contextRow(line));
i++;
continue;
}
// Collect the next run of deletions, then the run of insertions that
// immediately follows. Pair them 1:1 into side-by-side change rows;
// any leftovers spill as solo rows.
//
// e.g. del A, del B, del C, ins X, ins Y
// → (A ↔ X) (B ↔ Y) (C ↔ ∅)
const deletions = [];
while (i < lines.length && lines[i].type === 'delete') {
deletions.push(lines[i]);
i++;
}
const insertions = [];
while (i < lines.length && lines[i].type === 'insert') {
insertions.push(lines[i]);
i++;
}
const pairCount = Math.min(deletions.length, insertions.length);
for (let p = 0; p < pairCount; p++) {
rows.push(pairedChangeRow(deletions[p], insertions[p]));
}
for (let p = pairCount; p < deletions.length; p++) {
rows.push(soloDeletionRow(deletions[p]));
}
for (let p = pairCount; p < insertions.length; p++) {
rows.push(soloInsertionRow(insertions[p]));
}
// Safety: skip unknown line types so the outer loop can't stall.
if (
i < lines.length
&& lines[i].type !== 'context'
&& lines[i].type !== 'delete'
&& lines[i].type !== 'insert'
) {
i++;
}
}
}
// ── Pass 2: group consecutive changed rows into navigation blocks ──
// Hunk headers and context rows each close the currently-active block.
const changeBlocks = [];
let currentBlock = null;
rows.forEach((row, idx) => {
const isChanged = row.leftKind === 'del' || row.rightKind === 'ins';
if (row.leftKind === 'hunk' || !isChanged) {
currentBlock = null;
return;
}
if (currentBlock) {
currentBlock.endIdx = idx;
} else {
currentBlock = { startIdx: idx, endIdx: idx };
changeBlocks.push(currentBlock);
}
});
return { rows, changeBlocks };
}
// Wrap-around modulo so Prev at block 0 jumps to the last block. JS's
// native `%` returns -1 for `-1 % 5`; the double-mod gives 4. Clamp to 0
// when there are no blocks at all.
export function wrapIndex(idx, length) {
if (length <= 0) return 0;
return ((idx % length) + length) % length;
}

View File

@@ -0,0 +1,55 @@
import { escapeHtml } from 'utils/response';
// Skip word-level diff on lines longer than this (Diff2Html default is 10k).
const MAX_HIGHLIGHT_LENGTH = 5000;
export function createHighlightCache() {
// Map of `${left}\x00${right}` → { left, right } HTML. The null byte separator safely delimits the pair.
const cache = new Map();
return {
// Word-level diff for a paired del+ins row. Returns { left, right } HTML
// with <del>/<ins> around changed words.
getWordDiff(leftContent, rightContent) {
const key = `${leftContent}\x00${rightContent}`;
const hit = cache.get(key);
if (hit !== undefined) return hit; // cache hit → skip the ~1-3ms recomputation
// Diff2Html ships as a global UMD bundle loaded from /public/static.
const D2H = typeof window !== 'undefined' && window.Diff2Html;
let result;
if (D2H && typeof D2H.diffHighlight === 'function') {
try {
// diffHighlight's internal parser expects each line to start with a
// prefix char (-, +, space) and strips it. We prepend '-' / '+' here
// purely to satisfy that input shape.
const out = D2H.diffHighlight(
`-${leftContent}`,
`+${rightContent}`,
false, // isCombined: standard two-way diff, not a git combined diff
{ matching: 'words', maxLineLengthHighlight: MAX_HIGHLIGHT_LENGTH }
);
// out.oldLine/newLine.content already has the <del>/<ins> markup we want.
result = {
left: out?.oldLine?.content ?? escapeHtml(leftContent),
right: out?.newLine?.content ?? escapeHtml(rightContent)
};
} catch {
// Malformed input or Diff2Html internal error — fall back so the row still renders.
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
}
} else {
// Diff2Html bundle hasn't loaded (test env, CSP, etc.) — escape only.
result = { left: escapeHtml(leftContent), right: escapeHtml(rightContent) };
}
cache.set(key, result); // stored so Virtuoso remounts of this same row hit cache
return result;
},
// Empties the cache when a fresh diff replaces the current one.
clear() {
cache.clear();
}
};
}

View File

@@ -1,13 +1,21 @@
import { useRef, useEffect, useState } from 'react';
import { useTheme } from 'providers/Theme/index';
import { IconLoader2 } from '@tabler/icons';
import { Virtuoso } from 'react-virtuoso';
import { IconLoader2, IconChevronUp, IconChevronDown } from '@tabler/icons';
import Modal from 'components/Modal';
import StatusBadge from 'ui/StatusBadge';
import { buildRows, wrapIndex } from './buildRows';
import { createHighlightCache } from './highlightCache';
import DiffRow from './DiffRow';
const SpecDiffModal = ({ specDrift, onClose }) => {
const diffRef = useRef(null);
const { displayedTheme } = useTheme();
const virtuosoRef = useRef(null);
const [cache] = useState(createHighlightCache);
const [isRendering, setIsRendering] = useState(true);
const [parseError, setParseError] = useState(false);
const [rows, setRows] = useState([]);
const [changeBlocks, setChangeBlocks] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const addedCount = specDrift?.added?.length || 0;
const modifiedCount = specDrift?.modified?.length || 0;
@@ -17,54 +25,119 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
? `v${specDrift.storedVersion || '?'} → v${specDrift.newVersion}`
: null;
// Parse + build row list, deferred via setTimeout so the spinner paints first.
useEffect(() => {
const { Diff2Html } = window;
if (!diffRef?.current || !Diff2Html || !specDrift?.unifiedDiff) {
if (!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
setParseError(false);
// setTimeout yields to the browser so the spinner paints before parse blocks.
const timer = setTimeout(() => {
try {
const parsed = Diff2Html.parse(specDrift.unifiedDiff, {
outputFormat: 'side-by-side',
matching: 'lines'
});
const built = buildRows(parsed);
setRows(built.rows);
setChangeBlocks(built.changeBlocks);
setCurrentIndex(0);
cache.clear();
} catch (err) {
console.error('SpecDiffModal: failed to parse unified diff', err);
setParseError(true);
}
setIsRendering(false);
}, 0);
return () => clearTimeout(timer);
}, [specDrift?.unifiedDiff, cache]);
const goToChange = (idx) => {
if (!changeBlocks.length) return;
const nextIndex = wrapIndex(idx, changeBlocks.length);
const targetBlock = changeBlocks[nextIndex];
const fromBlock = changeBlocks[currentIndex];
const gap = fromBlock ? Math.abs(targetBlock.startIdx - fromBlock.startIdx) : 0;
virtuosoRef.current?.scrollToIndex({
index: targetBlock.startIdx,
align: 'center',
behavior: gap > 500 ? 'auto' : 'smooth'
});
// Safe: Diff2Html is loaded from a local static bundle (public/static/diff2Html.js)
diffRef.current.innerHTML = diffHtml;
setIsRendering(false);
}, [displayedTheme, specDrift?.unifiedDiff]);
setCurrentIndex(nextIndex);
};
const activeBlock = changeBlocks[currentIndex] || null;
const renderItem = (index) => (
<DiffRow
row={rows[index]}
active={!!activeBlock && index >= activeBlock.startIdx && index <= activeBlock.endIdx}
cache={cache}
/>
);
const showNav = !!specDrift?.unifiedDiff && !parseError;
const changeCount = changeBlocks.length;
const counterLabel
= changeCount === 0 ? 'No changes' : `${currentIndex + 1} of ${changeCount} changes`;
return (
<Modal
size="xl"
title="Spec Diff"
hideFooter
handleCancel={onClose}
>
<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>
<div className="spec-diff-header">
<div className="spec-diff-header-left">
<div className="spec-diff-badges">
<div>Endpoint Changes:</div>
{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>
<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>
{showNav && (
<div className="spec-diff-nav">
<span className="spec-diff-nav-counter">{counterLabel}</span>
<div className="spec-diff-nav-buttons">
<button
type="button"
className="spec-diff-nav-btn"
onClick={() => goToChange(currentIndex - 1)}
disabled={changeCount === 0}
title="Previous change"
>
<IconChevronUp size={14} strokeWidth={1.75} /> Previous
</button>
<button
type="button"
className="spec-diff-nav-btn"
onClick={() => goToChange(currentIndex + 1)}
disabled={changeCount === 0}
title="Next change"
>
<IconChevronDown size={14} strokeWidth={1.75} /> Next
</button>
</div>
</div>
)}
</div>
<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">
{specDrift?.storedSpecMissing ? 'Current Spec (missing)' : 'Current Spec'}
</span>
<span className="diff-column-label">Updated Spec</span>
</div>
{isRendering && (
@@ -73,7 +146,25 @@ const SpecDiffModal = ({ specDrift, onClose }) => {
<span>Loading diff...</span>
</div>
)}
<div ref={diffRef} style={{ display: isRendering ? 'none' : 'block' }}></div>
{!isRendering && parseError && (
<div className="text-diff-empty">
Diff couldn&apos;t be rendered. Please file an issue with the spec.
</div>
)}
{!isRendering && !parseError && rows.length > 0 && (
<Virtuoso
ref={virtuosoRef}
totalCount={rows.length}
itemContent={renderItem}
// Must match .diff-row min-height in OpenAPISyncTab/StyledWrapper.js
fixedItemHeight={18}
increaseViewportBy={400}
style={{ height: '100%' }}
/>
)}
{!isRendering && !parseError && rows.length === 0 && (
<div className="text-diff-empty">No changes to display.</div>
)}
</>
) : (
<div className="text-diff-empty">No text diff available.</div>

View File

@@ -1503,143 +1503,154 @@ const StyledWrapper = styled.div`
.text-diff-container {
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.border.border1};
overflow: auto;
overflow: hidden;
display: flex;
flex-direction: column;
background: ${(props) => props.theme.bg};
.diff-column-headers {
display: flex;
display: grid;
grid-template-columns: 9ch 1fr 9ch 1fr;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
position: sticky;
top: 0;
z-index: 2;
background: ${(props) => props.theme.bg};
flex-shrink: 0;
.diff-column-label {
flex: 1;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: ${(props) => props.theme.colors.text.muted};
grid-column: span 2;
&:first-child {
border-right: 1px solid ${(props) => props.theme.border.border1};
&:last-child {
border-left: 1px solid ${(props) => props.theme.border.border1};
}
}
}
.d2h-wrapper {
background-color: ${(props) => props.theme.bg} !important;
/* The Virtuoso scroll container fills the rest of the modal body. */
> div[data-testid='virtuoso-scroller'],
> div:last-child {
flex: 1 1 auto;
min-height: 0;
}
/* Active block gets a persistent 3px yellow bar down the left edge. */
.diff-row {
display: grid;
grid-template-columns: 9ch 1fr 9ch 1fr;
font-family: 'Fira Code', monospace;
font-size: 12px;
line-height: 1.5;
/* Must match Virtuoso's fixedItemHeight in SpecDiffModal/index.js */
min-height: 18px;
color: ${(props) => props.theme.text};
font-variant-ligatures: none;
font-feature-settings: 'liga' 0, 'calt' 0;
}
.d2h-file-wrapper {
border: none;
border-radius: 0;
margin-bottom: 0;
/* Vertical divider between the two side-by-side panels. Applied to the
third grid cell (right-side line number), aligned with the header's
existing border-right on the "Current Spec" label. */
.diff-row > *:nth-child(3) {
border-left: 1px solid ${(props) => props.theme.border.border1};
}
.d2h-file-header {
display: none;
.diff-row.diff-row-focused > .diff-cell-num:first-child {
box-shadow: inset 3px 0 0 ${(props) => props.theme.colors.text.yellow};
}
.d2h-files-diff {
width: 100%;
.diff-row.diff-row-focused > .diff-cell-num {
color: ${(props) => props.theme.text};
font-weight: 600;
}
.d2h-file-side-diff:first-child {
border-right: 1px solid ${(props) => props.theme.border.border1};
.diff-cell-num {
padding: 0 0.5em;
text-align: right;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.diff-kind-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
}
&.diff-kind-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
}
&.diff-kind-empty {
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
}
}
.d2h-code-side-linenumber {
background: transparent !important;
position: static !important;
.diff-cell-code {
display: flex;
min-width: 0;
padding: 0 0.5em;
white-space: pre;
overflow: hidden;
&.diff-kind-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 22%, transparent);
}
&.diff-kind-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent);
}
&.diff-kind-empty {
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.05)};
}
}
.d2h-diff-tbody {
tr td { border: none !important; }
.diff-prefix {
width: 1em;
flex-shrink: 0;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
}
.d2h-ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 15%, transparent) !important;
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
.diff-content {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: thin;
del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent);
text-decoration: none;
}
ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent);
text-decoration: none;
}
}
.d2h-del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 15%, transparent) !important;
border-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
}
/* Hunk row must be exactly 18px so Virtuoso's fixedItemHeight is
accurate. Borders would add 2px; we use inset box-shadow to get the
visual top/bottom rule without consuming layout space. Vertical
padding removed for the same reason. */
.diff-row-hunk {
grid-template-columns: 1fr;
background-color: ${(props) => rgba(props.theme.colors.text.muted, 0.08)};
color: ${(props) => props.theme.colors.text.muted};
box-shadow:
inset 0 1px 0 ${(props) => props.theme.border.border1},
inset 0 -1px 0 ${(props) => props.theme.border.border1};
.d2h-file-diff .d2h-ins.d2h-change {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 25%, transparent) !important;
}
.d2h-file-diff .d2h-del.d2h-change {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 20%, transparent) !important;
}
.d2h-code-line ins,
.d2h-code-side-line ins {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 40%, transparent) !important;
text-decoration: none;
}
.d2h-code-line del,
.d2h-code-side-line del {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 40%, transparent) !important;
text-decoration: none;
}
.d2h-code-line,
.d2h-code-side-line {
color: ${(props) => props.theme.text} !important;
word-break: break-all;
}
.d2h-code-line-ctn {
word-break: break-all;
}
.d2h-tag {
font-size: 9px;
font-weight: 500;
padding: 1px 5px;
border-radius: ${(props) => props.theme.border.radius.sm};
text-transform: uppercase;
letter-spacing: 0.02em;
border: none;
}
.d2h-changed-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.warning} 15%, transparent);
color: ${(props) => props.theme.colors.text.warning};
}
.d2h-added-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.green} 13%, transparent);
color: ${(props) => props.theme.colors.text.green};
}
.d2h-deleted-tag {
background-color: color-mix(in srgb, ${(props) => props.theme.colors.text.danger} 13%, transparent);
color: ${(props) => props.theme.colors.text.danger};
}
.d2h-renamed-tag,
.d2h-moved-tag {
display: none;
}
.d2h-file-wrapper,
.d2h-file-diff,
.d2h-code-wrapper,
.d2h-diff-table,
.d2h-code-line,
.d2h-code-side-line,
.d2h-code-line-ctn,
.d2h-code-linenumber,
.d2h-code-side-linenumber {
font-family: 'Fira Code', monospace !important;
font-size: 12px !important;
.diff-cell-hunk {
padding: 0 0.75em;
font-family: 'Fira Code', monospace;
font-size: 11px;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
@@ -1661,6 +1672,15 @@ const StyledWrapper = styled.div`
}
.spec-diff-modal {
.spec-diff-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 1rem;
}
.spec-diff-badges {
display: flex;
gap: 0.5rem;
@@ -1671,12 +1691,50 @@ const StyledWrapper = styled.div`
.spec-diff-subtitle {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
margin: 0 0 0.75rem 0;
}
.spec-diff-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
.spec-diff-nav-counter {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
.spec-diff-nav-buttons {
display: flex;
gap: 0.5rem;
}
.spec-diff-nav-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: ${(props) => props.theme.font.size.xs};
background: none;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.text};
cursor: pointer;
&:hover:not(:disabled) {
background: ${(props) => props.theme.background.surface1};
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.spec-diff-body {
.text-diff-container {
max-height: calc(80vh - 140px);
height: calc(80vh - 140px);
}
}
}

View File

@@ -15,7 +15,7 @@ import ExpandableEndpointRow from '../EndpointChangeSection/ExpandableEndpointRo
import ConfirmSyncModal from '../ConfirmSyncModal';
import SpecDiffModal from '../SpecDiffModal';
import Help from 'components/Help';
import { setReviewDecision, setReviewDecisions, selectTabUiState } from 'providers/ReduxStore/slices/openapi-sync';
import { setReviewDecision, setReviewDecisions } from 'providers/ReduxStore/slices/openapi-sync';
/**
* Categorize remoteDrift endpoints using three-way merge.
@@ -87,9 +87,20 @@ const SyncReviewPage = ({
onApplySync
}) => {
const dispatch = useDispatch();
const tabUiState = useSelector(selectTabUiState(collectionUid));
const tabUiState = useSelector((state) => state.openapiSync?.tabUiState?.[collectionUid] || {});
const [showConfirmation, setShowConfirmation] = useState(false);
const [showSpecDiffModal, setShowSpecDiffModal] = useState(false);
const [isOpeningSpecDiff, setIsOpeningSpecDiff] = useState(false);
// setTimeout lets the button's spinner paint before the modal mounts —
// without it, React batches both state updates and the spinner never shows.
const handleOpenSpecDiff = () => {
setIsOpeningSpecDiff(true);
setTimeout(() => {
setShowSpecDiffModal(true);
setIsOpeningSpecDiff(false);
}, 0);
};
const { specAddedEndpoints, specUpdatedEndpoints, localUpdatedEndpoints, specRemovedEndpoints } = useMemo(() => {
if (!remoteDrift) {
@@ -228,8 +239,17 @@ const SyncReviewPage = ({
{(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
className="bulk-btn"
onClick={handleOpenSpecDiff}
disabled={isOpeningSpecDiff || showSpecDiffModal}
>
{isOpeningSpecDiff ? (
<IconLoader2 size={12} className="animate-spin" />
) : (
<IconArrowsDiff size={12} />
)}{' '}
View Spec Diff
</button>
)}
{decidableEndpoints.length > 0 && (

View File

@@ -1,9 +1,16 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector, useStore } from 'react-redux';
import toast from 'react-hot-toast';
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { clearCollectionState, setCollectionUpdate, setStoredSpecMeta } from 'providers/ReduxStore/slices/openapi-sync';
import {
clearCollectionState,
setCollectionUpdate,
setStoredSpec,
setStoredSpecMeta,
setDrift
} from 'providers/ReduxStore/slices/openapi-sync';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isHttpUrl } from 'utils/url/index';
import { flattenItems } from 'utils/collections/index';
@@ -19,19 +26,23 @@ const useOpenAPISync = (collection) => {
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 drift = useSelector((state) => state.openapiSync?.drift?.[collection.uid] || null);
const specDrift = drift?.specDrift || null;
const collectionDrift = drift?.collectionDrift || null;
const remoteDrift = drift?.remoteDrift || null;
const storedSpec = useSelector((state) => state.openapiSync?.storedSpec?.[collection.uid] || null);
const updateDrift = (patch) => dispatch(setDrift({ collectionUid: collection.uid, patch }));
// useStore: tabs are read only inside handlers — useSelector would re-render on every tab change.
const store = useStore();
const isConfigured = !!openApiSyncConfig?.sourceUrl;
const updateStoredSpec = (spec) => {
setStoredSpec(spec);
dispatch(setStoredSpec({ collectionUid: collection.uid, spec }));
dispatch(setStoredSpecMeta({
collectionUid: collection.uid,
title: spec?.info?.title || null,
@@ -72,6 +83,7 @@ const useOpenAPISync = (collection) => {
const openEndpointInTab = (endpointId) => {
const itemUid = endpointUidMap[endpointId];
if (!itemUid) return;
const tabs = store.getState().tabs?.tabs || [];
const existingTab = tabs.find((t) => t.uid === itemUid);
if (existingTab) {
dispatch(focusTab({ uid: itemUid }));
@@ -86,14 +98,13 @@ const useOpenAPISync = (collection) => {
}
};
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);
if (clear) updateDrift({ collectionDrift: null });
setIsDriftLoading(true);
try {
const { ipcRenderer } = window;
@@ -102,7 +113,7 @@ const useOpenAPISync = (collection) => {
});
if (!result.error) {
setCollectionDrift(result);
updateDrift({ collectionDrift: result, itemCountAtLastFetch: httpItemCount });
}
} catch (err) {
console.error('Error loading collection drift:', err);
@@ -122,9 +133,7 @@ const useOpenAPISync = (collection) => {
setIsLoading(true);
setError(null);
setFileNotFound(false);
setSpecDrift(null);
setRemoteDrift(null);
setCollectionDrift(null);
updateDrift({ fetching: true });
try {
const { ipcRenderer } = window;
@@ -146,14 +155,13 @@ const useOpenAPISync = (collection) => {
return;
}
setSpecDrift(result);
updateDrift({ specDrift: result, lastChecked: Date.now() });
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
}));
@@ -167,7 +175,7 @@ const useOpenAPISync = (collection) => {
console.error('Error computing remote drift:', remoteComparison.error);
setError(remoteComparison.error);
} else {
setRemoteDrift(remoteComparison);
updateDrift({ remoteDrift: remoteComparison });
}
}
@@ -181,24 +189,25 @@ const useOpenAPISync = (collection) => {
dispatch(setCollectionUpdate({
collectionUid: collection.uid,
hasUpdates: false,
diff: null,
error: formatIpcError(err) || 'Failed to check for updates'
}));
} finally {
updateDrift({ fetching: false });
setIsLoading(false);
}
};
useEffect(() => {
if (isConfigured) {
if (isConfigured && !drift?.specDrift && !drift?.fetching) {
checkForUpdates();
}
}, [isConfigured]);
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
// Reload drift when the collection's HTTP item count differs from what was recorded at the last fetch.
useEffect(() => {
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
prevItemCountRef.current = httpItemCount;
if (!isConfigured) return;
const cachedCount = drift?.itemCountAtLastFetch;
if (cachedCount !== undefined && cachedCount !== httpItemCount && !drift?.fetching) {
loadCollectionDrift();
}
}, [httpItemCount, isConfigured]);
@@ -245,7 +254,7 @@ const useOpenAPISync = (collection) => {
});
if (result.isValid === false) {
setSpecDrift(result);
updateDrift({ specDrift: result });
setError(result.error);
return;
}
@@ -263,15 +272,15 @@ const useOpenAPISync = (collection) => {
// Check if collection already matches the spec
if (result.newSpec) {
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
const initialDrift = 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);
const isInSync = !initialDrift.error
&& (!initialDrift.missing || initialDrift.missing.length === 0)
&& (!initialDrift.modified || initialDrift.modified.length === 0)
&& (!initialDrift.localOnly || initialDrift.localOnly.length === 0);
if (isInSync) {
// Collection matches — save spec file silently to complete setup
@@ -299,15 +308,12 @@ const useOpenAPISync = (collection) => {
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 tabs = store.getState().tabs?.tabs || [];
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
if (specTab) {
dispatch(closeTabs({ tabUids: [specTab.uid] }));
@@ -337,7 +343,7 @@ const useOpenAPISync = (collection) => {
compareSpec: currentSpecDrift.newSpec
});
if (!remoteComparison.error) {
setRemoteDrift(remoteComparison);
updateDrift({ remoteDrift: remoteComparison });
}
} catch (err) {
console.error('Error reloading remote drift:', err);

View File

@@ -39,7 +39,6 @@ const StyledWrapper = styled.div`
.section-divider {
height: 1px;
background: ${(props) => props.theme.input.border};
margin: 10px 0;
}
.tables-container {
@@ -75,7 +74,7 @@ const StyledWrapper = styled.div`
thead {
color: ${(props) => props.theme.table.thead.color} !important;
background: ${(props) => props.theme.sidebar.bg};
background: ${(props) => props.theme.table.striped};
user-select: none;
td {
@@ -100,9 +99,8 @@ const StyledWrapper = styled.div`
tr {
transition: background 0.1s ease;
height: 30px;
td {
padding: 0 10px !important;
padding: 0px 10px !important;
border: none !important;
vertical-align: middle;
background: transparent;
@@ -111,7 +109,7 @@ const StyledWrapper = styled.div`
}
tr:hover:not(.row-editing) td {
background: ${(props) => props.theme.sidebar.bg};
background: ${(props) => props.theme.background.surface0};
cursor: pointer;
}
@@ -120,7 +118,7 @@ const StyledWrapper = styled.div`
}
tr.section-heading-row td {
font-weight: 600;
font-weight: 700;
padding: 6px 10px !important;
user-select: none;
}
@@ -131,8 +129,28 @@ const StyledWrapper = styled.div`
}
tr.section-last-row td {
border-bottom: none !important;
}
tr.section-spacer-row {
height: 8px;
pointer-events: none;
}
tr.section-spacer-row td {
padding: 0 !important;
height: 8px;
line-height: 8px;
font-size: 0;
background: transparent !important;
border: none !important;
border-bottom: solid 1px ${(props) => props.theme.border.border0} !important;
}
tr.section-spacer-row:hover td {
background: transparent !important;
cursor: default;
}
}
.keybinding-row {
@@ -180,7 +198,7 @@ const StyledWrapper = styled.div`
}
.shortcut-input--editing {
outline: 1px solid #E4AE49;
outline: 1px solid ${(props) => props.theme.status.warning.border};
border-radius: 4px;
min-width: 100%;
max-width: 100%;
@@ -189,7 +207,7 @@ const StyledWrapper = styled.div`
}
.shortcut-input--error.shortcut-input--editing {
outline: 1px solid #CE4F3B;
outline: 1px solid ${(props) => props.theme.status.danger.border};
min-width: 100%;
max-width: 100%;
}
@@ -220,39 +238,41 @@ const StyledWrapper = styled.div`
align-items: center;
justify-content: center;
min-width: 20px;
height: 22px;
height: 20px;
padding: 2px;
border-radius: 3px;
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.background.base};
color: ${(props) => props.theme.table.input.color};
font-size: 12px;
font-size: 10px;
font-weight: 500;
line-height: 1;
}
tbody tr.row-success td {
background: #2E8A540F;
tbody tr.row-success td,
tbody tr.row-success:hover td {
background: ${(props) => props.theme.status.success.background} !important;
}
tbody tr.row-error td {
background: #D32F2F0F;
tbody tr.row-error td,
tbody tr.row-error:hover td {
background: ${(props) => props.theme.status.danger.background} !important;
}
.success-icon {
color: #2E8A54;
color: ${(props) => props.theme.status.success.text};
display: inline-flex;
align-items: center;
}
.error-icon {
color: #CE4F3B;
color: ${(props) => props.theme.status.danger.text};
display: inline-flex;
align-items: center;
}
.input-error-icon {
color: #CE4F3B;
color: ${(props) => props.theme.status.danger.text};
display: inline-flex;
align-items: center;
margin-left: auto;
@@ -294,6 +314,11 @@ const StyledWrapper = styled.div`
border-radius: 6px;
padding: 0px 6px;
cursor: pointer;
&:disabled {
opacity: 0.45;
cursor: not-allowed;
}
}
.action-btn {

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState, useEffect } from 'react';
import React, { useMemo, useRef, useState, useEffect, Fragment } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -10,6 +10,7 @@ import { savePreferences } from 'providers/ReduxStore/slices/app';
import { KEY_BINDING_SECTIONS } from 'providers/Hotkeys/keyMappings.js';
import { Tooltip } from 'react-tooltip';
import ToggleSwitch from 'components/ToggleSwitch/index';
import toast from 'react-hot-toast';
const SEP = '+bind+';
const getOS = () => (isMacOS() ? 'mac' : 'windows');
@@ -82,10 +83,10 @@ const renderDisplayValue = (displayValue, os) => {
return (
<span className="shortcut-pills">
{parsed.map((keysArr, index) => (
<React.Fragment key={index}>
<Fragment key={index}>
{index > 0 && <span className="shortcut-separator"> - </span>}
{renderKeycaps(keysArr, os)}
</React.Fragment>
</Fragment>
))}
</span>
);
@@ -218,23 +219,21 @@ const RESERVED_BY_OS = {
comboSignature(['f12']) // Dashboard (older macOS)
]),
windows: new Set([
// System-level shortcuts (intercepted by Windows before reaching the app)
comboSignature(['alt', 'tab']),
comboSignature(['alt', 'shift', 'tab']),
comboSignature(['alt', 'f4']),
comboSignature(['f1']), // Windows Help
comboSignature(['alt', 'esc']),
comboSignature(['alt', 'space']),
comboSignature(['ctrl', 'alt', 'delete']),
comboSignature(['command', 'l']),
comboSignature(['command', 'd']),
comboSignature(['command', 'e']),
comboSignature(['command', 'r']),
comboSignature(['command', 'i']),
comboSignature(['command', 's']),
comboSignature(['command', 'a']),
comboSignature(['command', 'x']),
comboSignature(['command', 'm']),
comboSignature(['command', 'tab']),
comboSignature(['ctrl', 'shift', 'esc']),
// Function keys
comboSignature(['f1']), // Windows Help
comboSignature(['f11']), // Fullscreen toggle
comboSignature(['f12']), // DevTools
// Undo/Redo - standard text editing shortcuts that browsers handle natively
comboSignature(['ctrl', 'z']),
comboSignature(['ctrl', 'y']),
comboSignature(['ctrl', 'shift', 'z']),
// Toggle Developer Tools
comboSignature(['ctrl', 'shift', 'i'])
@@ -493,7 +492,7 @@ const Keybindings = () => {
if (buildUsedSignatures(action).has(sig)) {
return {
code: ERROR.DUPLICATE,
message: 'That shortcut is already in use.'
message: 'This shortcut is already in use.'
};
}
@@ -562,9 +561,24 @@ const Keybindings = () => {
return next;
});
persistToPreferences(action, def);
// Remove the entry from user preferences entirely so falls back to default.
// This also keeps `hasCustomizedKeybindings` accurate.
const nextKeyBindings = { ...(preferences?.keyBindings || {}) };
delete nextKeyBindings[action];
const updatedPreferences = {
...preferences,
keyBindings: nextKeyBindings
};
dispatch(savePreferences(updatedPreferences));
};
const hasCustomizedKeybindings = useMemo(() => {
const userKeyBindings = preferences?.keyBindings || {};
return Object.keys(userKeyBindings).length > 0;
}, [preferences?.keyBindings]);
const resetAllKeybindings = () => {
const updatedPreferences = {
...preferences,
@@ -572,6 +586,7 @@ const Keybindings = () => {
};
dispatch(savePreferences(updatedPreferences));
toast.success('All shortcuts have been reset to default');
};
const startEditing = (action) => {
@@ -799,6 +814,7 @@ const Keybindings = () => {
onClick={resetAllKeybindings}
className="reset-btn"
data-testid="reset-all-keybindings-btn"
disabled={!hasCustomizedKeybindings}
>
Reset Default
</button>
@@ -817,7 +833,7 @@ const Keybindings = () => {
</thead>
<tbody>
{groupedKeyMappings.map((section, sectionIndex) => (
<React.Fragment key={section.heading}>
<Fragment key={section.heading}>
<tr className="section-heading-row">
<td colSpan={2}>{section.heading}</td>
</tr>
@@ -946,7 +962,12 @@ const Keybindings = () => {
</tr>
);
})}
</React.Fragment>
{sectionIndex < groupedKeyMappings.length - 1 && (
<tr className="section-spacer-row" aria-hidden="true">
<td colSpan={2}>&nbsp;</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -9,6 +9,8 @@ import SingleLineEditor from 'components/SingleLineEditor';
import AssertionOperator from './AssertionOperator';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const unaryOperators = [
'isEmpty',
@@ -55,6 +57,9 @@ const isUnaryOperator = (operator) => unaryOperators.includes(operator);
const Assertions = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-assert-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const assertions = item.draft ? get(item, 'draft.request.assertions') : get(item, 'request.assertions');
@@ -166,7 +171,7 @@ const Assertions = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full">
<StyledWrapper className="w-full" ref={wrapperRef}>
<EditableTable
tableId="assertions"
columns={columns}
@@ -178,6 +183,7 @@ const Assertions = ({ item, collection }) => {
testId="assertions-table"
columnWidths={assertionsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('assertions', widths)}
initialScroll={scroll}
/>
</StyledWrapper>
);

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -11,10 +11,15 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const FormUrlEncodedParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-body-formUrlEncoded-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const params = item.draft ? get(item, 'draft.request.body.formUrlEncoded') : get(item, 'request.body.formUrlEncoded');
@@ -81,7 +86,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full">
<StyledWrapper className="w-full" ref={wrapperRef}>
<EditableTable
tableId="form-url-encoded"
columns={columns}
@@ -92,6 +97,7 @@ const FormUrlEncodedParams = ({ item, collection }) => {
onReorder={handleParamDrag}
columnWidths={formUrlEncodedWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('form-url-encoded', widths)}
initialScroll={scroll}
/>
</StyledWrapper>
);

View File

@@ -10,6 +10,7 @@ import useLocalStorage from 'hooks/useLocalStorage';
import CodeEditor from 'components/CodeEditor/index';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { IconSend, IconRefresh, IconWand, IconPlus, IconTrash } from '@tabler/icons';
import ToolHint from 'components/ToolHint/index';
import { toastError } from 'utils/common/error';
@@ -70,8 +71,10 @@ const MessageToolbar = ({
const SingleGrpcMessage = ({ message, item, collection, index, methodType, handleRun, canClientSendMultipleMessages, isLast }) => {
const dispatch = useDispatch();
const editorRef = useRef(null);
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [grpcScroll, setGrpcScroll] = usePersistedState({ key: `request-grpc-msg-scroll-${item.uid}-${index}`, default: 0 });
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const isConnectionActive = useSelector((state) => state.collections.activeConnections.includes(item.uid));
@@ -199,6 +202,7 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, handl
/>
<div className="editor-container">
<CodeEditor
ref={editorRef}
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
@@ -209,6 +213,8 @@ const SingleGrpcMessage = ({ message, item, collection, index, methodType, handl
onSave={onSave}
mode="application/ld+json"
enableVariableHighlighting={true}
initialScroll={grpcScroll}
onScroll={setGrpcScroll}
/>
</div>
</div>

View File

@@ -111,7 +111,7 @@ const HttpRequestPane = ({ item, collection }) => {
const tabPanel = useMemo(() => {
const Component = TAB_PANELS[requestPaneTab];
return Component ? <Component item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
return Component ? <Component key={item.uid} item={item} collection={collection} /> : <div className="mt-4">404 | Not found</div>;
}, [requestPaneTab, item, collection]);
if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) {

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -15,11 +15,16 @@ import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import path from 'utils/common/path';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
import { isWindowsOS } from 'utils/common/platform';
const MultipartFormParams = ({ item, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-body-multipartForm-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const params = item.draft ? get(item, 'draft.request.body.multipartForm') : get(item, 'request.body.multipartForm');
@@ -222,7 +227,7 @@ const MultipartFormParams = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full">
<StyledWrapper className="w-full" ref={wrapperRef}>
<EditableTable
tableId="multipart-form"
columns={columns}
@@ -233,6 +238,7 @@ const MultipartFormParams = ({ item, collection }) => {
onReorder={handleParamDrag}
columnWidths={multipartFormWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('multipart-form', widths)}
initialScroll={scroll}
/>
</StyledWrapper>
);

View File

@@ -94,6 +94,7 @@ const ArgValueInput = ({ value, onChange, field }) => {
onChange={(e) => onChange(e.target.value)}
onClick={(e) => e.stopPropagation()}
placeholder="Enter value"
className="mousetrap"
/>
);
};
@@ -139,7 +140,7 @@ const InputObjectFields = ({ namedType, parentKey, fieldPath, indent, argValues,
)}
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isEnabled}
onChange={(e) => {
e.stopPropagation();
@@ -230,12 +231,6 @@ const FieldNode = ({
role="treeitem"
aria-expanded={isExpanded}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
@@ -248,7 +243,7 @@ const FieldNode = ({
</span>
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
@@ -268,12 +263,6 @@ const FieldNode = ({
role="treeitem"
aria-expanded={canExpand ? isExpanded : undefined}
onClick={handleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleExpand(e);
}
}}
tabIndex={0}
>
<span className="field-indent" style={{ width: indent }} />
@@ -288,7 +277,7 @@ const FieldNode = ({
</span>
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isChecked}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
@@ -315,7 +304,7 @@ const FieldNode = ({
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
@@ -369,7 +358,7 @@ const FieldNode = ({
<span className="input-object-chevron-spacer" />
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={() => onToggleArg && onToggleArg(field.path, arg.name)}
onClick={(e) => e.stopPropagation()}
@@ -419,12 +408,6 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
@@ -438,7 +421,7 @@ const InputObjectArgRow = ({ arg, argKey, fieldPath, isArgEnabled, sectionIndent
</span>
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}
@@ -486,12 +469,6 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
className="arg-row"
style={{ paddingLeft: sectionIndent + 8 }}
onClick={toggleExpand}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleExpand(e);
}
}}
tabIndex={0}
role="button"
aria-expanded={isExpanded}
@@ -505,7 +482,7 @@ const ListArgRow = ({ arg, fieldPath, isArgEnabled, argValue, sectionIndent, onT
</span>
<input
type="checkbox"
className="field-checkbox"
className="field-checkbox mousetrap"
checked={isArgEnabled}
onChange={handleCheck}
onClick={(e) => e.stopPropagation()}

View File

@@ -211,7 +211,12 @@ const StyledWrapper = styled.div`
padding: 3px 8px;
font-size: 13px;
min-width: 0;
cursor: default;
cursor: pointer;
&:hover,
&:focus-visible {
background: ${(props) => props.theme.background.surface0};
}
.input-object-chevron {
width: 14px;

View File

@@ -175,6 +175,7 @@ const QueryBuilder = ({ schema, onQueryChange, editorValue, onVariablesChange, v
type="text"
placeholder="Search operations..."
value={searchText}
className="mousetrap"
onChange={(e) => setSearchText(e.target.value)}
/>
</div>

View File

@@ -137,6 +137,12 @@ export default class QueryEditor extends React.Component {
this.addOverlay();
setupLinkAware(editor);
// Add mousetrap class so Mousetrap captures shortcuts even when CodeMirror is focused
const cmInput = editor.getInputField();
if (cmInput) {
cmInput.classList.add('mousetrap');
}
}
componentDidUpdate(prevProps) {

View File

@@ -38,6 +38,14 @@ const Wrapper = styled.div`
}
}
.bulk-edit-bar {
position: sticky;
bottom: 0;
background: ${(props) => props.theme.bg};
padding-top: 8px;
padding-bottom: 4px;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import get from 'lodash/get';
import InfoTip from 'components/InfoTip';
import { useDispatch, useSelector } from 'react-redux';
@@ -14,6 +14,8 @@ import MultiLineEditor from 'components/MultiLineEditor';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import BulkEditor from '../../BulkEditor';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -25,6 +27,9 @@ const QueryParams = ({ item, collection }) => {
const pathParams = params.filter((param) => param.type === 'path');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-params-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
@@ -146,7 +151,7 @@ const QueryParams = ({ item, collection }) => {
}
return (
<StyledWrapper className="w-full flex flex-col">
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<div className="flex-1">
<div className="mb-3 title text-xs">Query</div>
<EditableTable
@@ -159,8 +164,9 @@ const QueryParams = ({ item, collection }) => {
onReorder={handleQueryParamDrag}
columnWidths={queryParamsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('query-params', widths)}
initialScroll={scroll}
/>
<div className="flex justify-end mt-2">
<div className="bulk-edit-bar flex justify-end mt-2">
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
@@ -191,6 +197,7 @@ const QueryParams = ({ item, collection }) => {
showAddRow={false}
columnWidths={pathParamsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('path-params', widths)}
initialScroll={scroll}
/>
) : (
<div className="title pr-2 py-3 mt-2 text-xs"></div>

View File

@@ -38,6 +38,12 @@ const QueryUrl = ({ item, collection, handleRun }) => {
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
useEffect(() => {
if (item.isTransient && !url && editorRef.current?.editor) {
setTimeout(() => editorRef.current?.editor?.focus(), 0);
}
}, [item.uid]);
const onSave = () => {
dispatch(saveRequest(item.uid, collection.uid));
};

View File

@@ -1,6 +1,5 @@
import React from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import CodeEditor from 'components/CodeEditor';
import FormUrlEncodedParams from 'components/RequestPane/FormUrlEncodedParams';
import MultipartFormParams from 'components/RequestPane/MultipartFormParams';
@@ -8,19 +7,18 @@ import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { updateRequestBody } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateRequestBodyScrollPosition } from 'providers/ReduxStore/slices/tabs';
import StyledWrapper from './StyledWrapper';
import FileBody from '../FileBody/index';
import { usePersistedState } from 'hooks/usePersistedState';
const RequestBody = ({ item, collection }) => {
const dispatch = useDispatch();
const editorRef = useRef(null);
const body = item.draft ? get(item, 'draft.request.body') : get(item, 'request.body');
const bodyMode = item.draft ? get(item, 'draft.request.body.mode') : get(item, 'request.body.mode');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const [bodyScroll, setBodyScroll] = usePersistedState({ key: `request-body-${bodyMode}-scroll-${item.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -35,15 +33,6 @@ const RequestBody = ({ item, collection }) => {
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onScroll = (editor) => {
dispatch(
updateRequestBodyScrollPosition({
uid: focusedTab.uid,
scrollY: editor.doc.scrollTop
})
);
};
if (['json', 'xml', 'text', 'sparql'].includes(bodyMode)) {
let codeMirrorMode = {
json: 'application/ld+json',
@@ -62,6 +51,7 @@ const RequestBody = ({ item, collection }) => {
return (
<StyledWrapper className="w-full" data-testid="request-body-editor">
<CodeEditor
ref={editorRef}
collection={collection}
item={item}
theme={displayedTheme}
@@ -71,8 +61,8 @@ const RequestBody = ({ item, collection }) => {
onEdit={onEdit}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
initialScroll={focusedTab?.requestBodyScrollPosition || 0}
initialScroll={bodyScroll}
onScroll={setBodyScroll}
mode={codeMirrorMode[bodyMode]}
enableVariableHighlighting={true}
showHintsFor={['variables']}

View File

@@ -1,26 +1,6 @@
import styled from 'styled-components';
const Wrapper = styled.div`
table {
width: 100%;
border-collapse: collapse;
font-weight: 500;
table-layout: fixed;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
td {
padding: 6px 10px;
}
}
.btn-action {
font-size: ${(props) => props.theme.font.size.base};
@@ -29,6 +9,14 @@ const Wrapper = styled.div`
}
}
.bulk-edit-bar {
position: sticky;
bottom: 0;
background: ${(props) => props.theme.bg};
padding-top: 8px;
padding-bottom: 4px;
}
input[type='text'] {
width: 100%;
border: solid 1px transparent;

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
@@ -12,6 +12,8 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import BulkEditor from '../../BulkEditor';
import { headerNameRegex, headerValueRegex } from 'utils/common/regex';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
@@ -22,6 +24,9 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-headers-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
// Get column widths from Redux
const focusedTab = tabs?.find((t) => t.uid === activeTabUid);
@@ -132,7 +137,7 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
}
return (
<StyledWrapper className="w-full">
<StyledWrapper className="w-full" ref={wrapperRef}>
<EditableTable
tableId="request-headers"
columns={columns}
@@ -141,12 +146,13 @@ const RequestHeaders = ({ item, collection, addHeaderText }) => {
defaultRow={defaultRow}
getRowError={getRowError}
reorderable={true}
initialScroll={scroll}
onReorder={handleHeaderDrag}
columnWidths={headersWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-headers', widths)}
/>
<div className="flex justify-end mt-2">
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
<div className="bulk-edit-bar flex justify-end mt-2">
<button className="btn-action text-link select-none" data-testid="bulk-edit-toggle" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
@@ -9,6 +9,7 @@ import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { usePersistedState } from 'hooks/usePersistedState';
const Script = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -33,14 +34,21 @@ const Script = ({ item, collection }) => {
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Refresh CodeMirror when tab becomes visible
const [preReqScroll, setPreReqScroll] = usePersistedState({ key: `request-pre-req-scroll-${item.uid}`, default: 0 });
const [postResScroll, setPostResScroll] = usePersistedState({ key: `request-post-res-scroll-${item.uid}`, default: 0 });
// Refresh CodeMirror when tab becomes visible and restore scroll position.
// CodeMirror's scrollTo() is silently ignored when the editor is inside a display:none container
// (TabsContent hides inactive tabs via display:none). So the scroll set during componentDidMount
// is lost for the hidden editor. After refresh() recalculates layout, we re-apply scrollTo().
useEffect(() => {
// Small delay to ensure DOM is updated
const timer = setTimeout(() => {
if (activeTab === 'pre-request' && preRequestEditorRef.current?.editor) {
preRequestEditorRef.current.editor.refresh();
preRequestEditorRef.current.editor.scrollTo(null, preReqScroll);
} else if (activeTab === 'post-response' && postResponseEditorRef.current?.editor) {
postResponseEditorRef.current.editor.refresh();
postResponseEditorRef.current.editor.scrollTo(null, postResScroll);
}
}, 0);
@@ -99,6 +107,7 @@ const Script = ({ item, collection }) => {
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
docKey="script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
@@ -108,6 +117,8 @@ const Script = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
</TabsContent>
@@ -115,6 +126,7 @@ const Script = ({ item, collection }) => {
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
docKey="script:post-response"
value={responseScript || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
@@ -124,6 +136,8 @@ const Script = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
</TabsContent>
</Tabs>

View File

@@ -1,17 +1,20 @@
import React from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import { usePersistedState } from 'hooks/usePersistedState';
const Tests = ({ item, collection }) => {
const dispatch = useDispatch();
const testsEditorRef = useRef(null);
const tests = item.draft ? get(item, 'draft.request.tests') : get(item, 'request.tests');
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [testsScroll, setTestsScroll] = usePersistedState({ key: `request-tests-scroll-${item.uid}`, default: 0 });
const onEdit = (value) => {
dispatch(
@@ -29,7 +32,9 @@ const Tests = ({ item, collection }) => {
return (
<div data-testid="test-script-editor">
<CodeEditor
ref={testsEditorRef}
collection={collection}
docKey="tests"
value={tests || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
@@ -39,6 +44,8 @@ const Tests = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
</div>
);

View File

@@ -11,7 +11,7 @@ import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { variableNameRegex } from 'utils/common/regex';
const VarsTable = ({ item, collection, vars, varType }) => {
const VarsTable = ({ item, collection, vars, varType, initialScroll = 0 }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const tabs = useSelector((state) => state.tabs.tabs);
@@ -106,6 +106,7 @@ const VarsTable = ({ item, collection, vars, varType }) => {
onReorder={handleVarDrag}
columnWidths={varsWidths}
onColumnWidthsChange={(widths) => handleColumnWidthsChange('request-vars', widths)}
initialScroll={initialScroll}
/>
</StyledWrapper>
);

View File

@@ -1,21 +1,27 @@
import React from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import VarsTable from './VarsTable';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const Vars = ({ item, collection }) => {
const requestVars = item.draft ? get(item, 'draft.request.vars.req') : get(item, 'request.vars.req');
const responseVars = item.draft ? get(item, 'draft.request.vars.res') : get(item, 'request.vars.res');
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `request-vars-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.flex-boundary', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="w-full flex flex-col">
<StyledWrapper className="w-full flex flex-col" ref={wrapperRef}>
<div>
<div className="mb-3 title text-xs">Pre Request</div>
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" />
<VarsTable item={item} collection={collection} vars={requestVars} varType="request" initialScroll={scroll} />
</div>
<div>
<div className="mt-3 mb-3 title text-xs">Post Response</div>
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" />
<VarsTable item={item} collection={collection} vars={responseVars} varType="response" initialScroll={scroll} />
</div>
</StyledWrapper>
);

View File

@@ -0,0 +1,127 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
align-items: center;
justify-content: center;
background: ${(props) => props.theme.bg};
transition: background-color 0.15s ease;
user-select: none;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&:focus-visible {
outline: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
outline-offset: -1px;
}
.panel-label {
font-size: 10px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
text-transform: uppercase;
letter-spacing: 0.5px;
}
.expand-icon {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
flex-shrink: 0;
}
&:hover .expand-icon {
opacity: 1;
color: ${(props) => props.theme.text};
}
&:hover .panel-label {
color: ${(props) => props.theme.text};
}
/* Horizontal layout - panels stacked on left or right */
&.horizontal {
width: 32px;
min-width: 32px;
height: 100%;
cursor: pointer;
border-left: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
border-right: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
position: relative;
&::before {
content: '';
position: absolute;
top: 0;
width: 8px;
height: 100%;
cursor: col-resize;
z-index: 2;
}
.indicator-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-90deg);
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
}
&.request {
border-left: none;
&::before { right: -4px; }
}
&.response {
border-right: none;
&::before { left: -4px; }
}
}
/* Vertical layout - panels stacked on top or bottom */
&.vertical {
width: 100%;
height: 28px;
min-height: 28px;
flex-direction: row;
cursor: pointer;
border-top: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
border-bottom: 1px solid ${(props) => props.theme.requestTabPanel.dragbar.border};
position: relative;
&::before {
content: '';
position: absolute;
left: 0;
width: 100%;
height: 8px;
cursor: row-resize;
z-index: 2;
}
.indicator-content {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
&.request {
border-top: none;
&::before { bottom: -4px; }
}
&.response {
border-bottom: none;
&::before { top: -4px; }
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,80 @@
import React, { useRef, useCallback } from 'react';
import { IconChevronDown, IconChevronUp } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const CollapsedPanelIndicator = ({
panelType, // 'request' or 'response'
isVertical,
onExpand,
onDragStart,
dragThresholdPx
}) => {
const dragThresholdSq = dragThresholdPx * dragThresholdPx; // to use in distance check
const label = panelType === 'request' ? 'Request' : 'Response';
const ChevronIcon = panelType === 'request' ? IconChevronDown : IconChevronUp;
const pointerDownRef = useRef(null);
const handlePointerDown = useCallback((e) => {
if (e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.style.cursor = isVertical ? 'row-resize' : 'col-resize';
pointerDownRef.current = { x: e.clientX, y: e.clientY };
}, [isVertical]);
const handlePointerMove = useCallback((e) => {
if (!pointerDownRef.current) return;
const dx = e.clientX - pointerDownRef.current.x;
const dy = e.clientY - pointerDownRef.current.y;
if (dx * dx + dy * dy > dragThresholdSq) {
pointerDownRef.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
onDragStart?.(e);
}
}, [onDragStart, dragThresholdSq]);
const handlePointerUp = useCallback((e) => {
if (!pointerDownRef.current) return;
pointerDownRef.current = null;
e.currentTarget.style.cursor = '';
e.currentTarget.releasePointerCapture(e.pointerId);
onExpand();
}, [onExpand]);
const handlePointerCancel = useCallback((e) => {
if (!pointerDownRef.current) return;
pointerDownRef.current = null;
e.currentTarget.style.cursor = '';
e.currentTarget.releasePointerCapture(e.pointerId);
}, []);
const handleKeyDown = useCallback((e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onExpand();
}
}, [onExpand]);
return (
<StyledWrapper
className={`collapsed-panel-indicator ${isVertical ? 'vertical' : 'horizontal'} ${panelType}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
aria-label={`Expand ${label} pane`}
title={`Click to expand ${label} pane, or drag to resize`}
>
<div className="indicator-content">
<ChevronIcon size={14} strokeWidth={2} className="expand-icon" />
<span className="panel-label">{label}</span>
</div>
</StyledWrapper>
);
};
export default CollapsedPanelIndicator;

View File

@@ -17,15 +17,12 @@ const RequestNotFound = ({ itemUid }) => {
};
useEffect(() => {
setTimeout(() => {
const timer = setTimeout(() => {
setShowErrorMessage(true);
}, 300);
return () => clearTimeout(timer);
}, []);
// add a delay component in react that shows a loading spinner
// and then shows the error message after a delay
// this will prevent the error message from flashing on the screen
if (!showErrorMessage) {
return null;
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { IconLoader2 } from '@tabler/icons';
const RequestTabPanelLoading = ({ name }) => {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted">
<IconLoader2 className="animate-spin" size={24} strokeWidth={1.5} />
<span>Loading {name ? `"${name}"` : 'request'}...</span>
</div>
);
};
export default RequestTabPanelLoading;

View File

@@ -17,12 +17,31 @@ const StyledWrapper = styled.div`
min-width: 0;
}
.main {
padding-bottom: 1rem;
}
&.request-collapsed .query-url-wrapper,
&.response-collapsed .query-url-wrapper {
padding-bottom: 0;
}
&.request-collapsed .main,
&.response-collapsed .main {
padding-bottom: 0;
}
&.request-collapsed .response-pane,
&.response-collapsed .request-pane {
padding-top: 1rem;
}
div.dragbar-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 10px;
min-width: 10px;
width: 12px;
min-width: 12px;
padding: 0;
cursor: col-resize;
background: transparent;
@@ -33,25 +52,35 @@ const StyledWrapper = styled.div`
height: 100%;
width: 1px;
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
pointer-events: none;
}
&:hover div.dragbar-handle {
border-left: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
&.vertical-layout {
.request-pane {
padding-bottom: 0.5rem;
}
.response-pane {
padding-top: 0.5rem;
}
&.request-collapsed .response-pane {
padding-top: 0;
}
&.response-collapsed .request-pane {
padding-bottom: 0;
}
div.dragbar-wrapper {
width: 100%;
height: 10px;
height: 12px;
cursor: row-resize;
padding: 0 1rem;
position: relative;
@@ -61,12 +90,14 @@ const StyledWrapper = styled.div`
height: 1px;
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.border};
pointer-events: none;
}
&:hover div.dragbar-handle {
border-left: none;
border-top: solid 1px ${(props) => props.theme.requestTabPanel.dragbar.activeBorder};
}
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import find from 'lodash/find';
import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
@@ -7,8 +7,8 @@ import HttpRequestPane from 'components/RequestPane/HttpRequestPane';
import GrpcRequestPane from 'components/RequestPane/GrpcRequestPane/index';
import ResponsePane from 'components/ResponsePane';
import GrpcResponsePane from 'components/ResponsePane/GrpcResponsePane';
import { findItemInCollection } from 'utils/collections';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { findItemInCollection, findItemInCollectionByPathname, areItemsLoading } from 'utils/collections';
import { cancelRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateGqlDocsOpen } from 'providers/ReduxStore/slices/tabs';
import RequestNotFound from './RequestNotFound';
import QueryUrl from 'components/RequestPane/QueryUrl/index';
@@ -26,6 +26,7 @@ import { produce } from 'immer';
import CollectionOverview from 'components/CollectionSettings/Overview';
import RequestNotLoaded from './RequestNotLoaded';
import RequestIsLoading from './RequestIsLoading';
import RequestTabPanelLoading from './RequestTabPanelLoading';
import FolderNotFound from './FolderNotFound';
import ExampleNotFound from './ExampleNotFound';
import WsQueryUrl from 'components/RequestPane/WsQueryUrl';
@@ -41,11 +42,14 @@ import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
import OpenAPISyncTab from 'components/OpenAPISyncTab';
import OpenAPISpecTab from 'components/OpenAPISpecTab';
import CollapsedPanelIndicator from './CollapsedPanelIndicator';
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 490;
const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const COLLAPSE_EDGE_THRESHOLD = 80;
const EXPAND_EDGE_THRESHOLD = 100;
const RequestTabPanel = () => {
const dispatch = useDispatch();
@@ -60,8 +64,10 @@ const RequestTabPanel = () => {
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
const isRequestTab = focusedTab && ['request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
useKeybinding('sendRequest', () => {
const isRequestTab = focusedTab && ['request', 'http-request', 'grpc-request', 'ws-request', 'graphql-request'].includes(focusedTab.type);
useKeybinding('sendRequest', (e) => {
e?.preventDefault?.();
e?.stopPropagation?.();
handleRun();
return false;
}, { enabled: !!isRequestTab, deps: [isRequestTab] });
@@ -89,10 +95,27 @@ const RequestTabPanel = () => {
});
const collection = find(collections, (c) => c.uid === focusedTab?.collectionUid);
const isItemsLoading = useMemo(() => {
return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
}, [collection?.mountStatus, collection]);
const [dragging, setDragging] = useState(false);
const draggingRef = useRef(false);
const { left: leftPaneWidth, top: topPaneHeight, reset: resetPaneBoundaries, setTop: setTopPaneHeight, setLeft: setLeftPaneWidth } = useTabPaneBoundaries(activeTabUid);
const {
left: leftPaneWidth,
top: topPaneHeight,
reset: resetPaneBoundaries,
setTop: setTopPaneHeight,
setLeft: setLeftPaneWidth,
requestPaneCollapsed,
responsePaneCollapsed,
collapseRequest,
expandRequest,
collapseResponse,
expandResponse
} = useTabPaneBoundaries(activeTabUid);
const previousTopPaneHeight = useRef(null); // Store height before devtools opens
// Not a recommended pattern here to have the child component
@@ -120,6 +143,27 @@ const RequestTabPanel = () => {
}
}, [dispatch, activeTabUid, showGqlDocs]);
// Refs for panel collapse/expand functions and current collapsed state
const collapseRequestRef = useRef(collapseRequest);
const collapseResponseRef = useRef(collapseResponse);
const expandRequestRef = useRef(expandRequest);
const expandResponseRef = useRef(expandResponse);
const requestPaneCollapsedRef = useRef(requestPaneCollapsed);
const responsePaneCollapsedRef = useRef(responsePaneCollapsed);
useEffect(() => {
collapseRequestRef.current = collapseRequest;
collapseResponseRef.current = collapseResponse;
expandRequestRef.current = expandRequest;
expandResponseRef.current = expandResponse;
requestPaneCollapsedRef.current = requestPaneCollapsed;
responsePaneCollapsedRef.current = responsePaneCollapsed;
}, [collapseRequest, collapseResponse, expandRequest, expandResponse, requestPaneCollapsed, responsePaneCollapsed]);
const stopDragging = useCallback(() => {
draggingRef.current = false;
setDragging(false);
}, []);
const handleMouseMove = useCallback((e) => {
if (!draggingRef.current || !mainSectionRef.current) return;
@@ -129,13 +173,47 @@ const RequestTabPanel = () => {
if (isVerticalLayoutRef.current) {
const newHeight = e.clientY - mainRect.top;
const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT;
// Clamp to bounds instead of returning early
const distanceFromBottom = mainRect.bottom - e.clientY;
if (newHeight < COLLAPSE_EDGE_THRESHOLD) {
if (!requestPaneCollapsedRef.current) collapseRequestRef.current();
return;
}
if (distanceFromBottom < COLLAPSE_EDGE_THRESHOLD) {
if (!responsePaneCollapsedRef.current) collapseResponseRef.current();
return;
}
if (requestPaneCollapsedRef.current && newHeight < EXPAND_EDGE_THRESHOLD) return;
if (responsePaneCollapsedRef.current && distanceFromBottom < EXPAND_EDGE_THRESHOLD) return;
if (requestPaneCollapsedRef.current) expandRequestRef.current();
if (responsePaneCollapsedRef.current) expandResponseRef.current();
const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight));
setTopPaneHeight(clampedHeight);
} else {
const newWidth = e.clientX - mainRect.left;
const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH;
// Clamp to bounds instead of returning early
const distanceFromRight = mainRect.right - e.clientX;
if (newWidth < COLLAPSE_EDGE_THRESHOLD) {
if (!requestPaneCollapsedRef.current) collapseRequestRef.current();
return;
}
if (distanceFromRight < COLLAPSE_EDGE_THRESHOLD) {
if (!responsePaneCollapsedRef.current) collapseResponseRef.current();
return;
}
if (requestPaneCollapsedRef.current && newWidth < EXPAND_EDGE_THRESHOLD) return;
if (responsePaneCollapsedRef.current && distanceFromRight < EXPAND_EDGE_THRESHOLD) return;
if (requestPaneCollapsedRef.current) expandRequestRef.current();
if (responsePaneCollapsedRef.current) expandResponseRef.current();
const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth));
setLeftPaneWidth(clampedWidth);
}
@@ -144,17 +222,45 @@ const RequestTabPanel = () => {
const handleMouseUp = useCallback((e) => {
if (draggingRef.current) {
e.preventDefault();
draggingRef.current = false;
setDragging(false);
stopDragging();
}
}, []);
}, [stopDragging]);
const handleDragbarMouseDown = useCallback((e) => {
const startDragging = useCallback((e) => {
e.preventDefault();
draggingRef.current = true;
setDragging(true);
}, []);
const applyPointerResize = useCallback((e) => {
if (!mainSectionRef.current) return;
const mainRect = mainSectionRef.current.getBoundingClientRect();
if (isVerticalLayoutRef.current) {
const newHeight = e.clientY - mainRect.top;
const maxHeight = mainRect.height - MIN_BOTTOM_PANE_HEIGHT;
const clampedHeight = Math.max(MIN_TOP_PANE_HEIGHT, Math.min(newHeight, maxHeight));
setTopPaneHeight(clampedHeight);
} else {
const newWidth = e.clientX - mainRect.left;
const maxWidth = mainRect.width - MIN_RIGHT_PANE_WIDTH;
const clampedWidth = Math.max(MIN_LEFT_PANE_WIDTH, Math.min(newWidth, maxWidth));
setLeftPaneWidth(clampedWidth);
}
}, [setTopPaneHeight, setLeftPaneWidth]);
const handleRequestIndicatorDragStart = useCallback((e) => {
expandRequest();
applyPointerResize(e);
startDragging(e);
}, [expandRequest, applyPointerResize, startDragging]);
const handleResponseIndicatorDragStart = useCallback((e) => {
expandResponse();
applyPointerResize(e);
startDragging(e);
}, [expandResponse, applyPointerResize, startDragging]);
useEffect(() => {
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
@@ -167,6 +273,7 @@ const RequestTabPanel = () => {
useEffect(() => {
if (!isVerticalLayout) return;
if (responsePaneCollapsed) return;
if (isConsoleOpen) {
// Store current height before reducing
@@ -185,7 +292,7 @@ const RequestTabPanel = () => {
previousTopPaneHeight.current = null;
}
}
}, [isConsoleOpen, isVerticalLayout]);
}, [isConsoleOpen, isVerticalLayout, responsePaneCollapsed]);
if (typeof window == 'undefined') {
return <div></div>;
@@ -220,16 +327,34 @@ const RequestTabPanel = () => {
}
if (focusedTab.type === 'response-example') {
const item = findItemInCollection(collection, focusedTab.itemUid);
const example = item?.examples?.find((ex) => ex.uid === focusedTab.uid);
if (!example) {
return <ExampleNotFound itemUid={focusedTab.itemUid} exampleUid={focusedTab.uid} />;
let item = findItemInCollection(collection, focusedTab.itemUid);
if (!item && focusedTab.pathname) {
item = findItemInCollectionByPathname(collection, focusedTab.pathname);
}
return <ResponseExample item={item} collection={collection} example={example} />;
let example = null;
if (item?.examples) {
example = item.examples.find((ex) => ex.uid === focusedTab.uid);
if (!example && focusedTab.exampleName) {
example = item.examples.find((ex) => ex.name === focusedTab.exampleName);
}
}
if (example) {
return <ResponseExample item={item} collection={collection} example={example} />;
}
const displayName = focusedTab.exampleName || focusedTab.name;
if (displayName && isItemsLoading) {
return <RequestTabPanelLoading name={displayName} />;
}
return <ExampleNotFound itemUid={focusedTab.itemUid} exampleUid={focusedTab.uid} />;
}
const item = findItemInCollection(collection, activeTabUid);
let item = findItemInCollection(collection, activeTabUid);
if (!item && focusedTab.pathname) {
item = findItemInCollectionByPathname(collection, focusedTab.pathname);
}
const isGrpcRequest = item?.type === 'grpc-request';
const isWsRequest = item?.type === 'ws-request';
@@ -242,7 +367,11 @@ const RequestTabPanel = () => {
}
if (focusedTab.type === 'collection-settings') {
return <CollectionSettings collection={collection} />;
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<CollectionSettings collection={collection} />
</ScopedPersistenceProvider>
);
}
if (focusedTab.type === 'collection-overview') {
@@ -250,12 +379,23 @@ const RequestTabPanel = () => {
}
if (focusedTab.type === 'folder-settings') {
const folder = findItemInCollection(collection, focusedTab.folderUid);
if (!folder) {
return <FolderNotFound folderUid={focusedTab.folderUid} />;
let folder = findItemInCollection(collection, focusedTab.folderUid);
if (!folder && focusedTab.pathname) {
folder = findItemInCollectionByPathname(collection, focusedTab.pathname);
}
return <FolderSettings collection={collection} folder={folder} />;
if (folder) {
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<FolderSettings collection={collection} folder={folder} />;
</ScopedPersistenceProvider>
);
}
if (focusedTab.name && isItemsLoading) {
return <RequestTabPanelLoading name={focusedTab.name} />;
}
return <FolderNotFound folderUid={focusedTab.folderUid} />;
}
if (focusedTab.type === 'environment-settings') {
@@ -267,18 +407,21 @@ const RequestTabPanel = () => {
}
if (focusedTab.type === 'openapi-spec') {
return <OpenAPISpecTab collection={collection} />;
return <OpenAPISpecTab collection={collection} tabUid={focusedTab.uid} />;
}
if (!item || !item.uid) {
return <RequestNotFound itemUid={activeTabUid} />;
const showLoading = focusedTab.name && isItemsLoading;
return showLoading
? <RequestTabPanelLoading name={focusedTab.name} />
: <RequestNotFound itemUid={activeTabUid} />;
}
if (item?.partial) {
if (item.partial) {
return <RequestNotLoaded item={item} collection={collection} />;
}
if (item?.loading) {
if (item.loading) {
return <RequestIsLoading item={item} />;
}
@@ -350,50 +493,76 @@ const RequestTabPanel = () => {
}
};
const requestPaneStyle = isVerticalLayout
? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
}
: {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
};
const getRequestPaneStyle = () => {
if (responsePaneCollapsed) {
return isVerticalLayout
? { flex: 1, width: '100%' }
: { flex: 1 };
}
return isVerticalLayout
? {
height: `${Math.max(topPaneHeight, MIN_TOP_PANE_HEIGHT)}px`,
minHeight: `${MIN_TOP_PANE_HEIGHT}px`,
width: '100%'
}
: {
width: `${Math.max(leftPaneWidth, MIN_LEFT_PANE_WIDTH)}px`
};
};
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<StyledWrapper
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${
isVerticalLayout ? 'vertical-layout' : ''
}`}
className={`flex flex-col flex-grow relative ${dragging ? 'dragging' : ''} ${isVerticalLayout ? 'vertical-layout' : ''
} ${requestPaneCollapsed ? 'request-collapsed' : ''} ${responsePaneCollapsed ? 'response-collapsed' : ''}`}
>
<div className="pt-3 pb-3 px-4">
<div className="query-url-wrapper pt-3 pb-4 px-4">
{renderQueryUrl()}
</div>
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow pb-4 relative overflow-auto`}>
<section className="request-pane" data-testid="request-pane">
<section ref={mainSectionRef} className={`main flex ${isVerticalLayout ? 'flex-col' : ''} flex-grow relative overflow-auto`}>
{requestPaneCollapsed ? (
<CollapsedPanelIndicator
panelType="request"
isVertical={isVerticalLayout}
onExpand={expandRequest}
onDragStart={handleRequestIndicatorDragStart}
dragThresholdPx={isVerticalLayout ? MIN_TOP_PANE_HEIGHT / 2 : MIN_LEFT_PANE_WIDTH / 2}
/>
) : (
<section className="request-pane" data-testid="request-pane" style={getRequestPaneStyle()}>
<div className="px-4 h-full">
{renderRequestPane()}
</div>
</section>
)}
{!requestPaneCollapsed && !responsePaneCollapsed && (
<div
className="px-4 h-full"
style={requestPaneStyle}
className="dragbar-wrapper"
onDoubleClick={(e) => {
e.preventDefault();
resetPaneBoundaries();
}}
onMouseDown={startDragging}
>
{renderRequestPane()}
<div className="dragbar-handle" />
</div>
</section>
)}
<div
className="dragbar-wrapper"
onDoubleClick={(e) => {
e.preventDefault();
resetPaneBoundaries();
}}
onMouseDown={handleDragbarMouseDown}
>
<div className="dragbar-handle" />
</div>
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane">
{renderResponsePane()}
</section>
{responsePaneCollapsed ? (
<CollapsedPanelIndicator
panelType="response"
isVertical={isVerticalLayout}
onExpand={expandResponse}
onDragStart={handleResponseIndicatorDragStart}
dragThresholdPx={isVerticalLayout ? MIN_BOTTOM_PANE_HEIGHT / 2 : MIN_RIGHT_PANE_WIDTH / 2}
/>
) : (
<section className="response-pane flex-grow overflow-x-auto" data-testid="response-pane" style={requestPaneCollapsed ? { flex: 1 } : undefined}>
{renderResponsePane()}
</section>
)}
</section>
{item.type === 'graphql-request' ? (
@@ -405,6 +574,17 @@ const RequestTabPanel = () => {
</DocExplorer>
</div>
) : null}
{dragging ? (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 9999,
cursor: isVerticalLayout ? 'row-resize' : 'col-resize',
userSelect: 'none'
}}
/>
) : null}
</StyledWrapper>
</ScopedPersistenceProvider>
);

View File

@@ -25,7 +25,7 @@ const StyledWrapper = styled.div`
}
.switcher-name {
max-width: 300px;
max-width: 124px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -151,6 +151,14 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
margin-left: 8px;
}
.display-icon{
padding: 4px;
box-sizing: content-box;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-radius: ${(props) => props.theme.border.radius.sm}
}
}
`;
export default StyledWrapper;

View File

@@ -393,6 +393,16 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
&& currentWorkspace.type !== 'default'
&& !isRenamingWorkspace;
const handleDisplayIconClick = (e) => {
const uid = isScratchCollection ? `${collection.uid}-overview` : collection.uid;
const type = isScratchCollection ? 'workspaceOverview' : 'collection-settings';
dispatch(addTab({
uid: uid,
collectionUid: collection.uid,
type: type
}));
};
return (
<StyledWrapper>
{closeWorkspaceModalOpen && currentWorkspace?.uid && (
@@ -411,7 +421,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
<div className="collection-switcher">
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<DisplayIcon size={18} strokeWidth={1.5} />
<DisplayIcon size={18} strokeWidth={1.5} className="cursor-pointer display-icon" />
<div className="workspace-input-wrapper">
<input
ref={workspaceNameInputRef}
@@ -459,69 +469,71 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
)}
</div>
) : (
<Dropdown
placement="bottom-start"
onCreate={onSwitcherCreate}
appendTo={() => document.body}
icon={(
<button className="switcher-trigger">
<DisplayIcon size={18} strokeWidth={1.5} />
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
</button>
)}
>
{/* Workspace section */}
{currentWorkspace && (
<>
<div className="label-item">Workspace</div>
<div
className={classNames('dropdown-item', {
'dropdown-item-active': isScratchCollection
})}
onClick={() => handleSwitchToWorkspace(currentWorkspace.uid)}
>
<div className="dropdown-icon">
<IconCategory size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">
{currentWorkspace.name || 'Untitled Workspace'}
</span>
{workspaceTabCount > 0 && (
<span className="dropdown-tab-count">{workspaceTabCount}</span>
)}
</div>
</>
)}
{/* Collections section */}
{mountedCollections.length > 0 && (
<>
<div className="dropdown-separator" />
<div className="label-item">Collections</div>
{mountedCollections.map((col) => {
const colTabCount = getTabCount(col.uid);
return (
<div className="flex flex-row justify-center items-center gap-x-1">
<DisplayIcon size={18} strokeWidth={1.5} className="cursor-pointer display-icon" onClick={handleDisplayIconClick} />
<Dropdown
placement="bottom-start"
onCreate={onSwitcherCreate}
appendTo={() => document.body}
icon={(
<button className="switcher-trigger">
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
</button>
)}
>
<div className="max-w-124 overflow-hidden">
{currentWorkspace && (
<>
<div className="label-item">Workspace</div>
<div
key={col.uid}
className={classNames('dropdown-item', {
'dropdown-item-active': !isScratchCollection && collection.uid === col.uid
'dropdown-item-active': isScratchCollection
})}
onClick={() => handleSwitchToCollection(col)}
onClick={() => handleSwitchToWorkspace(currentWorkspace.uid)}
>
<div className="dropdown-icon">
<IconBox size={16} strokeWidth={1.5} />
<IconCategory size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">{col.name || 'Untitled Collection'}</span>
{colTabCount > 0 && (
<span className="dropdown-tab-count">{colTabCount}</span>
<span className="dropdown-label collection-header-dropdown-label">
{currentWorkspace.name || 'Untitled Workspace'}
</span>
{workspaceTabCount > 0 && (
<span className="dropdown-tab-count">{workspaceTabCount}</span>
)}
</div>
);
})}
</>
)}
</Dropdown>
</>
)}
{mountedCollections.length > 0 && (
<>
<div className="dropdown-separator" />
<div className="label-item">Collections</div>
{mountedCollections.map((col) => {
const colTabCount = getTabCount(col.uid);
return (
<div
key={col.uid}
className={classNames('dropdown-item', {
'dropdown-item-active': !isScratchCollection && collection.uid === col.uid
})}
onClick={() => handleSwitchToCollection(col)}
>
<div className="dropdown-icon">
<IconBox size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label collection-header-dropdown-label">{col.name || 'Untitled Collection'}</span>
{colTabCount > 0 && (
<span className="dropdown-tab-count">{colTabCount}</span>
)}
</div>
);
})}
</>
)}
</div>
</Dropdown>
</div>
)}
{/* Workspace actions dropdown */}

View File

@@ -1,12 +1,13 @@
import React, { useState, useRef, useMemo } from 'react';
import React, { useState, useRef, useMemo, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { makeTabPermanent, syncTabUid } from 'providers/ReduxStore/slices/tabs';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { hasExampleChanges, findItemInCollection } from 'utils/collections';
import { hasExampleChanges, findItemInCollection, findItemInCollectionByPathname, areItemsLoading } from 'utils/collections';
import ExampleIcon from 'components/Icons/ExampleIcon';
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';
import RequestTabNotFound from '../RequestTab/RequestTabNotFound';
import RequestTabLoading from '../RequestTab/RequestTabLoading';
import StyledWrapper from '../RequestTab/StyledWrapper';
import GradientCloseButton from '../RequestTab/GradientCloseButton';
@@ -16,11 +17,32 @@ const ExampleTab = ({ tab, collection }) => {
const dropdownTippyRef = useRef();
// Get item and example data
const item = findItemInCollection(collection, tab.itemUid);
const example = useMemo(() => item?.examples?.find((ex) => ex.uid === tab.uid), [item?.examples, tab.uid]);
let item = findItemInCollection(collection, tab.itemUid);
if (!item && tab.pathname) {
item = findItemInCollectionByPathname(collection, tab.pathname);
}
const hasChanges = useMemo(() => hasExampleChanges(item, tab.uid), [item, tab.uid]);
const example = useMemo(() => {
if (!item?.examples) return null;
const byUid = item.examples.find((ex) => ex.uid === tab.uid);
if (byUid) return byUid;
if (tab.exampleName) {
return item.examples.find((ex) => ex.name === tab.exampleName);
}
return null;
}, [item?.examples, tab.uid, tab.exampleName]);
const hasChanges = useMemo(() => hasExampleChanges(item, example?.uid), [item, example?.uid]);
const isItemsLoading = useMemo(() => {
return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
}, [collection?.mountStatus, collection]);
useEffect(() => {
if (example && example.uid !== tab.uid) {
dispatch(syncTabUid({ oldUid: tab.uid, newUid: example.uid }));
}
}, [example, tab.uid, dispatch]);
const handleCloseClick = (event) => {
event.stopPropagation();
@@ -63,6 +85,8 @@ const ExampleTab = ({ tab, collection }) => {
};
if (!item || !example) {
const displayName = tab.exampleName || tab.name;
const showLoading = displayName && isItemsLoading;
return (
<StyledWrapper
className="flex items-center justify-between tab-container"
@@ -76,7 +100,11 @@ const ExampleTab = ({ tab, collection }) => {
}
}}
>
<RequestTabNotFound handleCloseClick={handleCloseClick} />
{showLoading ? (
<RequestTabLoading handleCloseClick={handleCloseClick} name={displayName} />
) : (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
)}
</StyledWrapper>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import GradientCloseButton from '../GradientCloseButton';
/**
* RequestTabLoading
*
* Displays a loading placeholder for a tab while its collection is mounting
* or the item is still being loaded. Shows the stored name from the snapshot.
*/
const RequestTabLoading = ({ handleCloseClick, name }) => {
return (
<>
<div className="flex items-baseline tab-label">
<span className="tab-name" title={name}>{name}</span>
</div>
<GradientCloseButton onClick={handleCloseClick} hasChanges={false} />
</>
);
};
export default RequestTabLoading;

View File

@@ -23,6 +23,7 @@ const StyledWrapper = styled.div`
position: relative;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 0.8125rem;
// so that the name does not cutoff when italicized

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import get from 'lodash/get';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { makeTabPermanent, syncTabUid } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, saveCollectionSettings, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import useKeybinding from 'hooks/useKeybinding';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
@@ -8,12 +8,13 @@ import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { findItemInCollection, hasRequestChanges } from 'utils/collections';
import { findItemInCollection, findItemInCollectionByPathname, hasRequestChanges, areItemsLoading } from 'utils/collections';
import ConfirmRequestClose from './ConfirmRequestClose';
import ConfirmCollectionClose from './ConfirmCollectionClose';
import ConfirmFolderClose from './ConfirmFolderClose';
import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnvironment';
import RequestTabNotFound from './RequestTabNotFound';
import RequestTabLoading from './RequestTabLoading';
import SpecialTab from './SpecialTab';
import StyledWrapper from './StyledWrapper';
import MenuDropdown from 'ui/MenuDropdown';
@@ -40,7 +41,24 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const menuDropdownRef = useRef();
const item = findItemInCollection(collection, tab.uid);
let item = findItemInCollection(collection, tab.uid);
if (!item && tab.pathname) {
item = findItemInCollectionByPathname(collection, tab.pathname);
}
useEffect(() => {
const isRequestType = tab.type === 'request'
|| tab.type === 'http-request'
|| tab.type === 'graphql-request'
|| tab.type === 'grpc-request'
|| tab.type === 'ws-request';
if (!isRequestType || !tab.pathname || !item?.uid || tab.uid === item.uid) {
return;
}
dispatch(syncTabUid({ oldUid: tab.uid, newUid: item.uid }));
}, [dispatch, item?.uid, tab.pathname, tab.type, tab.uid]);
const method = useMemo(() => {
if (!item) return;
@@ -58,6 +76,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const hasChanges = useMemo(() => hasRequestChanges(item), [item]);
const isItemsLoading = useMemo(() => {
return collection?.mountStatus === 'mounting' || areItemsLoading(collection);
}, [collection?.mountStatus, collection]);
const isWS = item?.type === 'ws-request';
useEffect(() => {
@@ -143,7 +165,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
setShowConfirmCollectionClose(true);
};
const folder = folderUid ? findItemInCollection(collection, folderUid) : null;
let folder = folderUid ? findItemInCollection(collection, folderUid) : null;
if (!folder && tab.type === 'folder-settings' && tab.pathname) {
folder = findItemInCollectionByPathname(collection, tab.pathname);
}
const handleCloseFolderSettings = (event) => {
if (!folder?.draft) {
@@ -225,7 +250,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
if (tab.type === 'environment-settings') {
if (collection?.environmentsDraft) {
const { environmentUid, variables } = collection.environmentsDraft;
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
if (environmentUid?.startsWith('dotenv:')) {
window.dispatchEvent(new Event('dotenv-save'));
} else {
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
}
}
} else if (tab.type === 'global-environment-settings') {
if (globalEnvironmentDraft) {
@@ -429,7 +458,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
/>
)}
{tab.type === 'folder-settings' && !folder ? (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
tab.name && isItemsLoading
? <RequestTabLoading handleCloseClick={handleCloseClick} name={tab.name} />
: <RequestTabNotFound handleCloseClick={handleCloseClick} />
) : tab.type === 'folder-settings' ? (
<SpecialTab handleCloseClick={handleCloseFolderSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} tabName={folder?.name} hasDraft={hasFolderDraft} />
) : tab.type === 'collection-settings' ? (
@@ -463,9 +494,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
if (!item) {
const showLoading = tab.name && isItemsLoading;
return (
<StyledWrapper
className="flex items-center justify-between tab-container"
className="flex items-center justify-between tab-container px-2"
onMouseDown={handleMouseDown}
onMouseUp={(e) => {
if (e.button === 1) {
@@ -476,7 +508,11 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
}}
>
<RequestTabNotFound handleCloseClick={handleCloseClick} />
{showLoading ? (
<RequestTabLoading handleCloseClick={handleCloseClick} name={tab.name} />
) : (
<RequestTabNotFound handleCloseClick={handleCloseClick} />
)}
</StyledWrapper>
);
}

View File

@@ -5,6 +5,21 @@ const StyledWrapper = styled.div`
overflow: hidden;
border-radius: 4px;
.response-pane-content {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
padding: 0 1rem;
margin-top: 1rem;
}
.response-tab-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
div.tabs {
div.tab {
padding: 6px 0px;

View File

@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import Overlay from '../Overlay';
import Placeholder from '../Placeholder';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import GrpcResponseHeaders from './GrpcResponseHeaders';
import GrpcStatusCode from './GrpcStatusCode';
import ResponseTime from '../ResponseTime/index';
@@ -100,9 +101,9 @@ const GrpcResponsePane = ({ item, collection }) => {
if (!item.response && !requestTimeline?.length) {
return (
<StyledWrapper className="flex h-full relative">
<HeightBoundContainer>
<Placeholder />
</StyledWrapper>
</HeightBoundContainer>
);
}
@@ -148,15 +149,17 @@ const GrpcResponsePane = ({ item, collection }) => {
rightContentRef={rightContentRef}
/>
</div>
<section className="flex flex-col flex-grow px-4 h-0 mt-4">
<section className="response-pane-content">
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
<Timeline collection={collection} item={item} activeTabUid={activeTabUid} />
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
<div className="response-tab-content">
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
<Timeline collection={collection} item={item} activeTabUid={activeTabUid} />
) : null
) : (
<>{getTabPanel(focusedTab.responsePaneTab)}</>
)}
</div>
</section>
</StyledWrapper>
);

View File

@@ -13,12 +13,15 @@ const QueryResponse = ({
disableRunEventListener,
headers,
error,
hideResultTypeSelector
hideResultTypeSelector,
docKey
}) => {
const { initialFormat, initialTab } = useInitialResponseFormat(dataBuffer, headers);
const previewFormatOptions = useResponsePreviewFormatOptions(dataBuffer, headers);
const [selectedFormat, setSelectedFormat] = useState('raw');
const [selectedTab, setSelectedTab] = useState('editor');
const [filter, setFilter] = useState('');
const [filterExpanded, setFilterExpanded] = useState(false);
useEffect(() => {
if (initialFormat !== null && initialTab !== null) {
@@ -56,6 +59,11 @@ const QueryResponse = ({
error={error}
selectedFormat={selectedFormat}
selectedTab={selectedTab}
filter={filter}
filterExpanded={filterExpanded}
onFilterChange={setFilter}
onFilterExpandChange={setFilterExpanded}
docKey={docKey}
/>
</div>
</StyledWrapper>

View File

@@ -1,10 +1,9 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useRef } from 'react';
import CodeEditor from 'components/CodeEditor/index';
import { get } from 'lodash';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { usePersistedState } from 'hooks/usePersistedState';
import { Document, Page } from 'react-pdf';
import 'pdfjs-dist/build/pdf.worker';
import 'react-pdf/dist/esm/Page/AnnotationLayer.css';
@@ -28,14 +27,13 @@ const QueryResultPreview = ({
codeMirrorMode,
previewMode,
disableRunEventListener,
displayedTheme
displayedTheme,
docKey
}) => {
const preferences = useSelector((state) => state.app.preferences);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const dispatch = useDispatch();
const editorRef = useRef(null);
const [responseScroll, setResponseScroll] = usePersistedState({ key: `response-body-scroll-${item.uid}`, default: 0 });
const [numPages, setNumPages] = useState(null);
function onDocumentLoadSuccess({ numPages }) {
@@ -52,28 +50,21 @@ const QueryResultPreview = ({
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const onScroll = (event) => {
dispatch(
updateResponsePaneScrollPosition({
uid: focusedTab.uid,
scrollY: event.doc.scrollTop
})
);
};
if (selectedTab === 'editor') {
return (
<CodeEditor
ref={editorRef}
collection={collection}
docKey={docKey || 'response:editor'}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
theme={displayedTheme}
onRun={onRun}
onSave={onSave}
onScroll={onScroll}
value={formattedData}
mode={codeMirrorMode}
initialScroll={focusedTab.responsePaneScrollPosition || 0}
initialScroll={responseScroll}
onScroll={setResponseScroll}
readOnly
/>
);

View File

@@ -102,7 +102,8 @@ const QueryResult = ({
filter,
filterExpanded,
onFilterChange,
onFilterExpandChange
onFilterExpandChange,
docKey
}) => {
const contentType = getContentType(headers);
const [showLargeResponse, setShowLargeResponse] = useState(false);
@@ -215,6 +216,7 @@ const QueryResult = ({
collection={collection}
disableRunEventListener={disableRunEventListener}
displayedTheme={displayedTheme}
docKey={docKey}
/>
</div>
{queryFilterEnabled && (

View File

@@ -1,11 +1,16 @@
import React from 'react';
import React, { useRef } from 'react';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const ResponseHeaders = ({ headers }) => {
const ResponseHeaders = ({ headers, item }) => {
const headersArray = typeof headers === 'object' ? Object.entries(headers) : [];
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `response-headers-scroll-${item?.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.response-tab-content', onChange: setScroll, initialValue: scroll });
return (
<StyledWrapper className="pb-4 w-full">
<StyledWrapper className="w-full" ref={wrapperRef}>
<div className="table-wrapper">
<table>
<thead>

View File

@@ -49,6 +49,26 @@ const StyledWrapper = styled.div`
}
}
.response-pane-content {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
position: relative;
padding: 0 1rem;
margin-top: 1rem;
&.has-script-error {
height: auto;
}
}
.response-tab-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.right-side-container {
min-width: 0;
flex-shrink: 1;

View File

@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
import {
IconChevronDown,
IconChevronRight,
@@ -78,12 +80,16 @@ const TestSection = ({
);
};
const TestResults = ({ results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
const TestResults = ({ item, results, assertionResults, preRequestTestResults, postResponseTestResults }) => {
results = results || [];
assertionResults = assertionResults || [];
preRequestTestResults = preRequestTestResults || [];
postResponseTestResults = postResponseTestResults || [];
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `response-tests-scroll-${item?.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: '.response-tab-content', onChange: setScroll, initialValue: scroll });
const [expandedSections, setExpandedSections] = useState({
preRequest: true,
tests: true,
@@ -112,7 +118,7 @@ const TestResults = ({ results, assertionResults, preRequestTestResults, postRes
}
return (
<StyledWrapper className="flex flex-col">
<StyledWrapper className="flex flex-col" ref={wrapperRef}>
<TestSection
title="Pre-Request Tests"
results={preRequestTestResults}

View File

@@ -23,6 +23,7 @@ const BodyBlock = ({ collection, data, dataBuffer, headers, error, item, type })
error={error}
key={item?.uid}
hideResultTypeSelector={type === 'request'}
docKey={`timeline-body:${type}:${item?.uid}`}
/>
</div>
) : (

View File

@@ -1,9 +1,11 @@
import React from 'react';
import React, { useRef } from 'react';
import StyledWrapper from './StyledWrapper';
import { findItemInCollection, findParentItemInCollection } from 'utils/collections/index';
import { get } from 'lodash';
import TimelineItem from './TimelineItem/index';
import GrpcTimelineItem from './GrpcTimelineItem/index';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
const getEffectiveAuthSource = (collection, item) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
@@ -43,7 +45,10 @@ const getEffectiveAuthSource = (collection, item) => {
return effectiveSource;
};
const Timeline = ({ collection, item, activeTabUid }) => {
const Timeline = ({ collection, item }) => {
const wrapperRef = useRef(null);
const [scroll, setScroll] = usePersistedState({ key: `response-timeline-scroll-${item.uid}`, default: 0 });
useTrackScroll({ ref: wrapperRef, selector: null, onChange: setScroll, initialValue: scroll });
// Get the effective auth source if auth mode is inherit
const authSource = getEffectiveAuthSource(collection, item);
const isGrpcRequest = item.type === 'grpc-request' || item.type === 'ws-request';
@@ -65,6 +70,7 @@ const Timeline = ({ collection, item, activeTabUid }) => {
return (
<StyledWrapper
className="pb-4 w-full flex flex-grow flex-col"
ref={wrapperRef}
>
{/* Timeline container with scrollbar */}
<div

View File

@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { updateResponsePaneTab } from 'providers/ReduxStore/slices/tabs';
import Overlay from '../Overlay';
import Placeholder from '../Placeholder';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import WSStatusCode from './WSStatusCode';
import ResponseTime from '../ResponseTime/index';
import Timeline from '../Timeline';
@@ -89,9 +90,9 @@ const WSResponsePane = ({ item, collection }) => {
if (!item.response && !requestTimeline?.length) {
return (
<StyledWrapper className="flex h-full relative">
<HeightBoundContainer>
<Placeholder />
</StyledWrapper>
</HeightBoundContainer>
);
}

View File

@@ -184,6 +184,7 @@ const ResponsePane = ({ item, collection }) => {
case 'tests': {
return (
<TestResults
item={item}
results={item.testResults}
assertionResults={item.assertionResults}
preRequestTestResults={item.preRequestTestResults}
@@ -296,13 +297,7 @@ const ResponsePane = ({ item, collection }) => {
rightContentExpandedWidth={RIGHT_CONTENT_EXPANDED_WIDTH}
/>
</div>
<section
className="flex flex-col min-h-0 relative px-4 auto overflow-auto mt-4"
style={{
flex: '1 1 0',
height: hasScriptError && showScriptErrorCard ? 'auto' : '100%'
}}
>
<section className={`response-pane-content ${hasScriptError && showScriptErrorCard ? 'has-script-error' : ''}`}>
{isLoading ? <Overlay item={item} collection={collection} /> : null}
{hasScriptError && showScriptErrorCard && (
<ScriptError
@@ -311,7 +306,7 @@ const ResponsePane = ({ item, collection }) => {
collection={collection}
/>
)}
<div className="flex-1 overflow-y-auto">
<div className="response-tab-content">
{!item?.response ? (
focusedTab?.responsePaneTab === 'timeline' && requestTimeline?.length ? (
<Timeline

View File

@@ -1,6 +1,6 @@
import React, { useMemo, useCallback, memo } from 'react';
import { useSelector } from 'react-redux';
import { IconDatabase, IconLoader2 } from '@tabler/icons';
import { IconBox, IconLoader2 } from '@tabler/icons';
import { areItemsLoading } from 'utils/collections';
const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => {
@@ -26,7 +26,7 @@ const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName
onClick={handleClick}
>
<div className="collection-item-content">
<IconDatabase size={16} strokeWidth={1.5} />
<IconBox size={16} strokeWidth={1.5} />
<span className="collection-item-name">{collectionName}</span>
</div>
{isLoading && (

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { IconChevronRight } from '@tabler/icons';
import { IconChevronRight, IconDots } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
const FolderBreadcrumbs = ({
collectionName,
@@ -8,30 +9,64 @@ const FolderBreadcrumbs = ({
onNavigateToRoot,
onNavigateToBreadcrumb
}) => {
const collapsed = breadcrumbs.length > 1 ? breadcrumbs.slice(0, -1) : [];
const last = breadcrumbs.length > 0 ? breadcrumbs[breadcrumbs.length - 1] : null;
return (
<>
<div className="breadcrumb-container">
<span
className={!isAtRoot ? 'collection-name-breadcrumb' : ''}
className={`breadcrumb-collection-name ${!isAtRoot ? 'collection-name-breadcrumb' : ''}`}
onClick={!isAtRoot ? onNavigateToRoot : undefined}
title={collectionName}
>
{collectionName}
</span>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
{collapsed.length > 0 && (
<>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<Dropdown
placement="bottom-start"
icon={(
<span className="breadcrumb-ellipsis-btn">
<IconDots size={16} strokeWidth={2} />
</span>
)}
>
<div className="breadcrumb-collapsed-dropdown">
{collapsed.map((breadcrumb, i) => (
<div
key={breadcrumb.uid}
className="dropdown-item breadcrumb-collapsed-item"
onClick={() => onNavigateToBreadcrumb(i)}
title={breadcrumb.name}
>
{breadcrumb.name}
</div>
))}
</div>
</Dropdown>
</>
)}
{last && (
<>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
className="collection-name-breadcrumb breadcrumb-last"
onClick={(e) => {
e.stopPropagation();
onNavigateToBreadcrumb(index);
onNavigateToBreadcrumb(breadcrumbs.length - 1);
}}
title={last.name}
>
{breadcrumb.name}
{last.name}
</span>
</React.Fragment>
))}
</>
)}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</>
</div>
);
};

View File

@@ -1,6 +1,10 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.bruno-modal-card.modal-sm {
width: 500px;
}
.save-request-form {
display: flex;
flex-direction: column;
@@ -54,6 +58,7 @@ const StyledWrapper = styled.div`
font-size: 14px;
margin-bottom: 12px;
color: ${(props) => props.theme.colors.text.muted};
min-width: 0;
}
.collection-name-clickable {
@@ -66,6 +71,49 @@ const StyledWrapper = styled.div`
.collection-name-chevron {
margin: 0 4px;
flex-shrink: 0;
}
.breadcrumb-container {
display: flex;
align-items: center;
overflow: hidden;
white-space: nowrap;
min-width: 0;
}
.breadcrumb-collection-name,
.breadcrumb-last {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 40px;
flex: 0 1 auto;
}
.breadcrumb-ellipsis-btn {
display: flex;
align-items: center;
cursor: pointer;
padding: 2px 4px;
border-radius: ${(props) => props.theme.border.radius.sm};
flex-shrink: 0;
color: ${(props) => props.theme.colors.text.yellow};
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
}
}
.breadcrumb-dropdown {
min-width: 120px;
max-width: 250px;
.dropdown-item {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.search-container {
@@ -114,10 +162,19 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
svg {
flex-shrink: 0;
}
}
.folder-item-name {
color: ${(props) => props.theme.text};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-empty-state {
@@ -157,6 +214,7 @@ const StyledWrapper = styled.div`
border-radius: ${(props) => props.theme.border.radius.sm};
user-select: none;
border: 1px solid ${(props) => props.theme.border.border1};
overflow: hidden;
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
@@ -168,11 +226,20 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
overflow: hidden;
svg {
flex-shrink: 0;
}
}
.collection-item-name {
color: ${(props) => props.theme.text};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collection-empty-state {

View File

@@ -361,7 +361,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
return (
<StyledWrapper>
<Modal
size="md"
size="sm"
title={isSelectingCollection ? 'Select Collection' : 'Save Request'}
handleCancel={handleCancel}
handleConfirm={handleConfirm}
@@ -547,8 +547,18 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
</ul>
) : (
<div className="collection-empty-state">
<p>No collections Yet</p>
<p>No Collections Yet</p>
<p className="collection-empty-state-subtitle">Collections help you organize your requests. Create your first one to save this request.</p>
<Button
type="button"
color="primary"
variant="outline"
icon={<IconFolder size={16} strokeWidth={1.5} />}
onClick={handleShowNewCollection}
className="mt-4"
>
New collection
</Button>
</div>
)}
</div>
@@ -748,7 +758,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
New Folder
</Button>
)}
{isSelectingCollection && !newCollection.show && (
{isSelectingCollection && !newCollection.show && availableCollections.length > 0 && (
<Button
type="button"
color="primary"

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