Compare commits

..

59 Commits

Author SHA1 Message Date
Bijin A B
5ef8867635 chore: fix flaky tests 2026-02-14 20:46:08 +05:30
Bijin A B
1d126dcb65 fix: flaky tests - standardize save keyboard shortcut across tests (#7141) 2026-02-14 03:58:02 +05:30
Bijin A B
0c3b828b09 fix: update header validation test to use triple-click for selecting all text (#7140) 2026-02-14 01:40:51 +05:30
Abhishek S Lal
e000e377d1 feat: move import collection from git url and spec url from enterprise edition to opensource (#7127)
* feat: move import collection from git url and spec url from enterprise edition to opensource

* fix: corrected a typo

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

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

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

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

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

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

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

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

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

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

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

* fix: batch name tracking and git utility fixes

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

- Remove unused lodash import (git.js)

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

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

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

* fix: handle undefined color in environment objects

Don't export if `undefined`

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

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

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

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

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

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

* style: improve create scratch request button styling

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

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

* refactor: improve scratch collection detection with path registration

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

* refactor: use CreateTransientRequest for scratch requests in workspace tabs

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

* refactor: unify WorkspaceTabs with RequestTabs system

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

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

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

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

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

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

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

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

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

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

* chore: remove redundant comments and simplify code

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

* fix: improve SaveTransientRequest collection mounting and selection flow

* refactor: use WorkspaceOverview directly, remove WorkspaceHome wrapper

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

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

* refactor: separate scratch collection handling from openCollectionEvent

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

* test: add scratch requests test suite

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

* refactor: address code review feedback for scratch requests feature

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

* feat: add unified collection header with context switcher dropdown

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

* chore: allow pr to comment

* refactor: improve scratch requests test cleanup with closeAllTabs helper

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

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

* fix: cURL import NDJSON request body as text

* fix: cURL import NDJSON request body as text

* fix: resolved comments for body.text

* fix: add NDJSON content type detection

---------

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

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

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

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

* feat: implement collection variable management in Bruno

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

* feat: enhance collection variable translations in Bruno

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

* feat: expand API hints for variable management in Bruno

* fix: test cases

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

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

* rm: duplicates

* refactor: remove bru.visualize transformation and associated tests

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

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

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

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

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

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

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

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

* empty commit

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

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

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

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

* refactor: enhance handling of await expressions in cookie translations

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

* refactor: update cookie access translations to use hasCookie method

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: lint issues

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

* rm: toast for color change

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

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

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

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

* Mark test script errors as failed in runner
    and CLI

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

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

* Preserving stopExecution in test script error handler

---------

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

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

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

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

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

---------

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

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

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

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

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

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

* Preserving stopExecution in test script error handler

---------

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

View File

@@ -9,6 +9,7 @@ on:
permissions:
contents: read
pull-requests: write
issues: write
checks: write
jobs:
@@ -72,7 +73,7 @@ jobs:
- name: Post PR comment
if: hashFiles('pr-comment.md') != ''
uses: actions/github-script@v8
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');

1
.gitignore vendored
View File

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

1183
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -100,6 +100,7 @@
}
},
"dependencies": {
"ajv": "^8.17.1"
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
}
}

View File

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

View File

@@ -4,10 +4,11 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import Bruno from 'components/Bruno';
import MenuDropdown from 'ui/MenuDropdown';
@@ -129,7 +130,10 @@ const AppTitleBar = () => {
});
const handleHomeClick = () => {
dispatch(showHomePage());
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
if (scratchCollectionUid) {
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
}
};
const handleWorkspaceSwitch = (workspaceUid) => {

View File

@@ -233,10 +233,17 @@ export default class CodeEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
this.editor.setCursor(cursor);
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
}
if (this.editor) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@ import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import { stripEnvVarUid } from 'utils/environments';
const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
@@ -92,6 +93,7 @@ const EnvironmentVariablesTable = ({
}, []);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
@@ -167,11 +169,13 @@ const EnvironmentVariablesTable = ({
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
const variablesReloaded = !isMount && !envChanged && prevEnvVariablesRef.current !== environment.variables;
prevEnvUidRef.current = environment.uid;
prevEnvVariablesRef.current = environment.variables;
mountedRef.current = true;
if ((isMount || envChanged) && hasDraftForThisEnv && draft?.variables) {
if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
formik.setValues([
...draft.variables,
{
@@ -184,16 +188,16 @@ const EnvironmentVariablesTable = ({
}
]);
}
}, [environment.uid, hasDraftForThisEnv, draft?.variables]);
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
const savedValuesJson = useMemo(() => {
return JSON.stringify(environment.variables || []);
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
}, [environment.variables]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, savedValuesJson, setIsModified]);
@@ -202,11 +206,11 @@ const EnvironmentVariablesTable = ({
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues);
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const hasActualChanges = currentValuesJson !== savedValuesJson;
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : null;
if (hasActualChanges) {
if (currentValuesJson !== existingDraftJson) {
@@ -318,7 +322,8 @@ const EnvironmentVariablesTable = ({
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
// Compare without UIDs since they can be different but the actual data is the same
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
if (!hasChanges) {
toast.error('No changes to save');
return;
@@ -441,8 +446,8 @@ const EnvironmentVariablesTable = ({
</tr>
)}
fixedItemHeight={35}
computeItemKey={(index, item) => item.variable.uid}
itemContent={(index, { variable, index: actualIndex }) => {
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
@@ -472,7 +477,7 @@ const EnvironmentVariablesTable = ({
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onBlur={() => handleNameBlur(actualIndex)}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}

View File

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

View File

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

View File

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

View File

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

View File

@@ -135,13 +135,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
};
const handleColorChange = (color) => {
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid))
.then(() => {
toast.success('Environment color updated!');
})
.catch(() => {
toast.error('An error occurred while updating the environment color');
});
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
};
return (

View File

@@ -22,6 +22,7 @@ import {
createDotEnvFile,
deleteDotEnvFile
} from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import classnames from 'classnames';
@@ -72,11 +73,24 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
dispatch(setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, [dispatch, collection.uid, selectedDotEnvFile]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
setSelectedDotEnvFile(null);
setActiveView('environment');
setIsDotEnvModified(false);
handleDotEnvModifiedChange(false);
return;
}
@@ -424,7 +438,7 @@ const EnvironmentList = ({
dispatch(deleteDotEnvFile(collection.uid, filename))
.then(() => {
toast.success(`${filename} file deleted!`);
setIsDotEnvModified(false);
handleDotEnvModifiedChange(false);
if (selectedDotEnvFile === filename) {
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
if (remainingFiles.length > 0) {
@@ -467,7 +481,7 @@ const EnvironmentList = ({
onSave={handleSaveDotEnv}
onSaveRaw={handleSaveDotEnvRaw}
isModified={isDotEnvModified}
setIsModified={setIsDotEnvModified}
setIsModified={handleDotEnvModifiedChange}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
collection={collection}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -154,10 +154,17 @@ class MultiLineEditor extends Component {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = String(this.props.value ?? '');
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change

View File

@@ -47,8 +47,8 @@ const General = () => {
.test('isNumber', 'Save Delay must be a number', (value) => {
return value === undefined || !isNaN(value);
})
.test('isValidInterval', 'Save Delay must be at least 100ms', (value) => {
return value === undefined || Number(value) >= 100;
.test('isValidInterval', 'Save Delay must be at least 500ms', (value) => {
return value === undefined || Number(value) >= 500;
})
}).test('intervalRequired', 'Save Delay is required when Auto Save is enabled', (value) => {
// If autosave is enabled, interval must be provided

View File

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

View File

@@ -156,8 +156,15 @@ export default class QueryEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
}
}
if (this.props.theme !== prevProps.theme && this.editor) {

View File

@@ -82,11 +82,15 @@ const Script = ({ item, collection }) => {
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{hasPreRequestScript && <StatusDot />}
{hasPreRequestScript && (
<StatusDot type={item.preRequestScriptErrorMessage ? 'error' : 'default'} />
)}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{hasPostResponseScript && <StatusDot />}
{hasPostResponseScript && (
<StatusDot type={item.postResponseScriptErrorMessage ? 'error' : 'default'} />
)}
</TabsTrigger>
</TabsList>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import get from 'lodash/get';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
@@ -172,7 +172,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
setShowConfirmGlobalEnvironmentClose(true);
};
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences'].includes(tab.type)) {
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences', 'workspaceOverview', 'workspaceEnvironments'].includes(tab.type)) {
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
@@ -236,6 +236,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{showConfirmEnvironmentClose && tab.type === 'environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={false}
isDotEnv={collection.environmentsDraft?.environmentUid?.startsWith('dotenv:')}
onCancel={() => setShowConfirmEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
@@ -244,7 +245,25 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
onSaveAndClose={() => {
const draft = collection.environmentsDraft;
if (draft?.environmentUid && draft?.variables) {
if (draft?.environmentUid?.startsWith('dotenv:')) {
const onSuccess = () => {
cleanup();
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmEnvironmentClose(false);
};
const onFailed = () => {
cleanup();
setShowConfirmEnvironmentClose(false);
};
const cleanup = () => {
window.removeEventListener('dotenv-save-complete', onSuccess);
window.removeEventListener('dotenv-save-failed', onFailed);
};
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
.then(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
@@ -263,6 +282,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{showConfirmGlobalEnvironmentClose && tab.type === 'global-environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={true}
isDotEnv={globalEnvironmentDraft?.environmentUid?.startsWith('dotenv:')}
onCancel={() => setShowConfirmGlobalEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearGlobalEnvironmentDraft());
@@ -271,7 +291,25 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
onSaveAndClose={() => {
const draft = globalEnvironmentDraft;
if (draft?.environmentUid && draft?.variables) {
if (draft?.environmentUid?.startsWith('dotenv:')) {
const onSuccess = () => {
cleanup();
dispatch(clearGlobalEnvironmentDraft());
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmGlobalEnvironmentClose(false);
};
const onFailed = () => {
cleanup();
setShowConfirmGlobalEnvironmentClose(false);
};
const cleanup = () => {
window.removeEventListener('dotenv-save-complete', onSuccess);
window.removeEventListener('dotenv-save-failed', onFailed);
};
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
.then(() => {
dispatch(clearGlobalEnvironmentDraft());
@@ -297,6 +335,10 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
<SpecialTab handleCloseClick={handleCloseEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} />
) : tab.type === 'global-environment-settings' ? (
<SpecialTab handleCloseClick={handleCloseGlobalEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} />
) : tab.type === 'workspaceOverview' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : tab.type === 'workspaceEnvironments' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}
@@ -474,19 +516,42 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
} catch (err) { }
}
async function handleCloseMultipleTabs(tabs) {
const tabUidsToClose = [];
for (const tab of tabs) {
const item = findItemInCollection(collection, tab.uid);
if (item && hasRequestChanges(item)) {
try {
await dispatch(saveRequest(item.uid, collection.uid, true));
} catch (err) {
continue;
}
}
if (tab?.uid) {
tabUidsToClose.push(tab.uid);
}
}
if (tabUidsToClose.length > 0) {
dispatch(closeTabs({ tabUids: tabUidsToClose }));
}
}
async function handleCloseOtherTabs() {
const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex);
await Promise.all(otherTabs.map((tab) => handleCloseTab(tab.uid)));
await handleCloseMultipleTabs(otherTabs);
}
async function handleCloseTabsToTheLeft() {
const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
await Promise.all(leftTabs.map((tab) => handleCloseTab(tab.uid)));
await handleCloseMultipleTabs(leftTabs);
}
async function handleCloseTabsToTheRight() {
const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
await Promise.all(rightTabs.map((tab) => handleCloseTab(tab.uid)));
await handleCloseMultipleTabs(rightTabs);
}
function handleCloseSavedTabs() {
@@ -497,7 +562,7 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
}
async function handleCloseAllTabs() {
await Promise.all(collectionRequestTabs.map((tab) => handleCloseTab(tab.uid)));
await handleCloseMultipleTabs(collectionRequestTabs);
}
const menuItems = useMemo(() => [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -127,6 +127,79 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
.collection-list {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
max-height: 320px;
overflow-y: auto;
background-color: ${(props) => props.theme.modal.body.bg};
padding: 8px 8px;
}
.collection-list-items {
display: flex;
flex-direction: column;
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
border-radius: ${(props) => props.theme.border.radius.sm};
}
.collection-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
transition: background-color 0.15s ease;
color: ${(props) => props.theme.text};
border-radius: ${(props) => props.theme.border.radius.sm};
user-select: none;
border: 1px solid ${(props) => props.theme.border.border1};
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
border-color: ${(props) => props.theme.colors.text.muted};
}
}
.collection-item-content {
display: flex;
align-items: center;
gap: 10px;
}
.collection-item-name {
color: ${(props) => props.theme.text};
font-weight: 500;
}
.collection-empty-state {
padding: 20px 16px;
text-align: center;
font-size: 14px;
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.icon-success {
color: ${(props) => props.theme.colors.success};
}
.custom-modal-footer {
display: flex;
justify-content: space-between;
@@ -163,30 +236,17 @@ const StyledWrapper = styled.div`
padding-top: 12px;
}
.new-folder-content {
.new-folder-header {
display: flex;
align-items: flex-start;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.new-folder-inputs {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.new-folder-name-input-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.new-folder-name-label {
font-size: 12px;
.new-folder-header-label {
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
}
.new-folder-input-row {
@@ -247,13 +307,41 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.new-folder-filesystem-label {
font-size: 12px;
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
color: ${(props) => props.theme.text};
}
.filesystem-input-container {
display: flex;
align-items: center;
background: ${(props) => props.theme.requestTabPanel.url.bg};
border-radius: 4px;
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
margin-top: 8px;
}
.filesystem-input-icon {
flex-shrink: 0;
margin-right: 8px;
color: ${(props) => props.theme.colors.text.yellow};
}
.filesystem-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.colors.text.yellow};
font-size: ${(props) => props.theme.font.size.base};
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.new-folder-toggle-filesystem-btn {

View File

@@ -1,21 +1,28 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Modal from 'components/Modal';
import SearchInput from 'components/SearchInput';
import Button from 'ui/Button';
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons';
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff, IconEdit, IconArrowBackUp } from '@tabler/icons';
import PathDisplay from 'components/PathDisplay/index';
import Help from 'components/Help';
import filter from 'lodash/filter';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import CollectionListItem from './CollectionListItem';
import FolderBreadcrumbs from './FolderBreadcrumbs';
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { removeSaveTransientRequestModal } from 'providers/ReduxStore/slices/collections';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { newFolder, closeTabs, mountCollection } from 'providers/ReduxStore/slices/collections/actions';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import { resolveRequestFilename } from 'utils/common/platform';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
import path from 'utils/common/path';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } from 'utils/collections';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import { itemSchema } from '@usebruno/schema';
import { uuid } from 'utils/common';
import { formatIpcError } from 'utils/common/error';
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
const dispatch = useDispatch();
@@ -28,12 +35,27 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const item = itemProp;
const collection = collectionProp;
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const allCollections = useSelector((state) => state.collections.collections);
const isScratchCollection = activeWorkspace?.scratchCollectionUid === collection?.uid;
const availableCollections = useMemo(() => {
if (!isScratchCollection || !activeWorkspace) return [];
return (activeWorkspace.collections || []).map((wc) => {
const fullCollection = allCollections.find((c) => c.pathname === wc.path);
// Use stable deterministic UID based on path to avoid duplicate Redux entries
const stableUid = wc.path ? `pending-${wc.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuid();
return fullCollection || { ...wc, uid: stableUid, mountStatus: 'unmounted' };
}).filter((c) => !workspaces.some((w) => w.scratchCollectionUid === c.uid));
}, [isScratchCollection, activeWorkspace, allCollections, workspaces]);
const handleClose = () => {
if (onClose) {
onClose();
return;
}
// Remove from Redux array
dispatch(removeSaveTransientRequestModal({ itemUid: item.uid }));
};
const [requestName, setRequestName] = useState(item?.name || '');
@@ -42,8 +64,28 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const [newFolderName, setNewFolderName] = useState('');
const [newFolderDirectoryName, setNewFolderDirectoryName] = useState('');
const [showFilesystemName, setShowFilesystemName] = useState(false);
const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false);
const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null);
const newFolderInputRef = useRef(null);
const [selectedTargetCollectionPath, setSelectedTargetCollectionPath] = useState(null);
const [isSelectingCollection, setIsSelectingCollection] = useState(isScratchCollection);
const folderTreeCollectionUid = selectedTargetCollectionPath
? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)?.uid
: collection?.uid;
const selectedTargetCollection = selectedTargetCollectionPath
? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)
: null;
useEffect(() => {
const isMounted = selectedTargetCollection?.mountStatus === 'mounted';
const isFullyLoaded = isMounted && !areItemsLoading(selectedTargetCollection);
if (selectedTargetCollectionPath && isFullyLoaded) {
setIsSelectingCollection(false);
}
}, [selectedTargetCollectionPath, selectedTargetCollection]);
const {
currentFolders,
breadcrumbs,
@@ -55,21 +97,27 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
getCurrentSelectedFolder,
reset,
isAtRoot
} = useCollectionFolderTree(collection?.uid);
} = useCollectionFolderTree(folderTreeCollectionUid);
const resetForm = () => {
setRequestName(item.name || '');
const resetForm = useCallback(() => {
setRequestName(item?.name || '');
setSearchText('');
reset();
setShowNewFolderInput(false);
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
};
setIsEditingFolderFilename(false);
setPendingFolderNavigation(null);
setSelectedTargetCollectionPath(null);
setIsSelectingCollection(isScratchCollection);
}, [item?.name, isScratchCollection, reset]);
useEffect(() => {
isOpen && item && resetForm();
}, [isOpen, item]);
if (isOpen && item) {
resetForm();
}
}, [isOpen, item, resetForm]);
useEffect(() => {
if (showNewFolderInput && newFolderInputRef.current) {
@@ -77,6 +125,16 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
}
}, [showNewFolderInput]);
useEffect(() => {
if (pendingFolderNavigation) {
const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation);
if (newFolder) {
navigateIntoFolder(newFolder.uid);
setPendingFolderNavigation(null);
}
}
}, [currentFolders, pendingFolderNavigation, navigateIntoFolder]);
const filteredFolders = useMemo(() => {
if (!searchText.trim()) {
return currentFolders;
@@ -90,16 +148,41 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
handleClose();
};
const handleSelectCollection = useCallback((selectedCollection) => {
const collectionPath = selectedCollection.path || selectedCollection.pathname;
const isMounted = selectedCollection.mountStatus === 'mounted';
const isFullyLoaded = isMounted && !areItemsLoading(selectedCollection);
setSelectedTargetCollectionPath(collectionPath);
if (isFullyLoaded) {
setIsSelectingCollection(false);
return;
}
if (!isMounted && selectedCollection.mountStatus !== 'mounting') {
dispatch(
mountCollection({
collectionUid: selectedCollection.uid || uuid(),
collectionPathname: collectionPath,
brunoConfig: selectedCollection.brunoConfig
})
);
}
}, [dispatch]);
const handleConfirm = async () => {
if (!item || !collection || !latestItem) {
return;
}
const targetCollection = selectedTargetCollection || collection;
try {
const { ipcRenderer } = window;
const selectedFolder = getCurrentSelectedFolder();
const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname;
const targetDirname = selectedFolder ? selectedFolder.pathname : targetCollection.pathname;
const trimmedName = requestName.trim();
if (!trimmedName || trimmedName.length === 0) {
@@ -107,6 +190,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
return;
}
if (!validateName(trimmedName)) {
toast.error(validateNameError(trimmedName));
return;
}
const sanitizedFilename = sanitizeName(trimmedName);
const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem };
@@ -116,23 +204,32 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
await itemSchema.validate(transformedItem);
const format = collection.format || DEFAULT_COLLECTION_FORMAT;
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
const targetFormat = targetCollection.format || DEFAULT_COLLECTION_FORMAT;
const sourceFormat = collection.format || DEFAULT_COLLECTION_FORMAT;
const targetFilename = resolveRequestFilename(sanitizedFilename, targetFormat);
const targetPathname = path.join(targetDirname, targetFilename);
await ipcRenderer.invoke('renderer:save-transient-request', {
sourcePathname: item.pathname,
targetDirname,
targetFilename,
request: transformedItem,
format
format: targetFormat,
sourceFormat
});
dispatch(
closeTabs({
tabUids: [item.uid]
insertTaskIntoQueue({
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid: targetCollection.uid,
itemPathname: targetPathname,
preview: false
})
);
dispatch(closeTabs({ tabUids: [item.uid] }));
dispatch({
type: 'collections/deleteItem',
payload: {
@@ -144,7 +241,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
toast.success('Request saved successfully');
handleClose();
} catch (err) {
toast.error(err?.message || 'Failed to save request');
toast.error(formatIpcError(err) || 'Failed to save request');
console.error('Error saving request:', err);
}
};
@@ -154,6 +251,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
};
const handleCancelNewFolder = () => {
@@ -161,26 +259,38 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
};
const handleNewFolderNameChange = (value) => {
setNewFolderName(value);
if (!showFilesystemName) {
if (!isEditingFolderFilename) {
setNewFolderDirectoryName(sanitizeName(value));
}
};
const handleDirectoryNameChange = (value) => {
setNewFolderDirectoryName(value);
};
const handleCreateNewFolder = async () => {
const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim());
const trimmedFolderName = newFolderName.trim();
if (!trimmedFolderName) {
toast.error('Folder name is required');
return;
}
if (!validateName(trimmedFolderName)) {
toast.error(validateNameError(trimmedFolderName));
return;
}
const directoryName = newFolderDirectoryName.trim() || sanitizeName(trimmedFolderName);
const parentFolder = getCurrentParentFolder();
const targetCollectionUid = selectedTargetCollection?.uid || collection?.uid;
try {
await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid));
await dispatch(newFolder(trimmedFolderName, directoryName, targetCollectionUid, parentFolder?.uid));
toast.success('New folder created!');
setPendingFolderNavigation(directoryName);
handleCancelNewFolder();
} catch (err) {
const errorMessage = err?.message || 'An error occurred while adding the folder';
@@ -193,6 +303,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setSearchText('');
};
const handleBreadcrumbNavigate = useCallback((index) => {
navigateToBreadcrumb(index);
setSearchText('');
}, [navigateToBreadcrumb]);
if (!isOpen) {
return null;
}
@@ -201,7 +316,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
<StyledWrapper>
<Modal
size="md"
title="Save Request"
title={isSelectingCollection ? 'Select Collection' : 'Save Request'}
handleCancel={handleCancel}
handleConfirm={handleConfirm}
confirmText="Save"
@@ -223,168 +338,253 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
spellCheck="false"
value={requestName}
onChange={(e) => setRequestName(e.target.value)}
autoFocus={true}
autoFocus={!isSelectingCollection}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="collections-section">
<div className="collections-label">Save to Collections</div>
{collection && (
<div
className={`collection-name ${!isAtRoot ? 'collection-name-clickable' : ''}`}
onClick={!isAtRoot ? navigateToRoot : undefined}
>
<span>{collection.name}</span>
{breadcrumbs.length > 0 && (
<div className="collections-label">
{isSelectingCollection ? 'Select a collection to save to' : 'Save to Collections'}
</div>
{isScratchCollection && (
<div className="collection-name">
<span
className={isSelectingCollection ? '' : 'collection-name-breadcrumb'}
onClick={!isSelectingCollection ? () => {
setIsSelectingCollection(true);
setSelectedTargetCollectionPath(null);
reset();
} : undefined}
>
Collections
</span>
{!isSelectingCollection && (
<>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
onClick={(e) => {
e.stopPropagation();
navigateToBreadcrumb(index);
setSearchText('');
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
</>
)}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</div>
)}
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
{isSelectingCollection ? (
<div className="collection-list">
{availableCollections.length > 0 ? (
<ul className="collection-list-items">
{availableCollections.map((coll) => {
const collPath = coll.path || coll.pathname;
return (
<CollectionListItem
key={collPath}
collectionUid={coll.uid}
collectionPath={collPath}
collectionName={coll.name}
isSelected={selectedTargetCollectionPath === collPath}
onSelect={() => handleSelectCollection(coll)}
/>
);
})}
</ul>
) : (
<div className="collection-empty-state">
No collections available in workspace. Please add a collection to the workspace first.
</div>
)}
</div>
) : (
<>
{!isScratchCollection && (selectedTargetCollection || collection) && (
<div className="collection-name">
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
</div>
)}
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-content">
<IconFolder size={16} strokeWidth={1.5} />
<div className="new-folder-inputs">
<div className="new-folder-name-input-wrapper">
{showFilesystemName && (
<label className="new-folder-name-label">New Folder name (in bruno)</label>
)}
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
handleCancelNewFolder();
}
}}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
</div>
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-header">
<IconFolder size={16} strokeWidth={1.5} />
<label className="new-folder-header-label">
{showFilesystemName ? 'New Folder name (in bruno)' : 'New Folder name'}
</label>
</div>
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
</div>
</div>
{showFilesystemName && (
<div className="new-folder-filesystem-wrapper">
<label className="new-folder-filesystem-label">Name on filesystem</label>
<input
type="text"
className="new-folder-input"
value={newFolderDirectoryName}
onChange={(e) => handleDirectoryNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
}
}}
/>
<div className="flex items-center justify-between">
<label className="new-folder-filesystem-label flex items-center font-medium">
Folder Name <small className="font-normal text-muted ml-1">(on filesystem)</small>
<Help width={300} placement="top">
<p>
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
</p>
</Help>
</label>
{isEditingFolderFilename ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(true)}
/>
)}
</div>
{isEditingFolderFilename ? (
<div className="relative flex flex-row gap-1 items-center justify-between">
<input
type="text"
className="block textbox mt-2 w-full"
placeholder="Folder Name"
value={newFolderDirectoryName}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={(e) => setNewFolderDirectoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">
<PathDisplay
iconType="folder"
baseName={newFolderDirectoryName}
/>
</div>
)}
</div>
)}
</div>
</div>
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
setIsEditingFolderFilename(false);
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
)}
</div>
</>
)}
</div>
</div>
<div className="custom-modal-footer">
<div className="footer-left">
{!showNewFolderInput && (
{!showNewFolderInput && !isSelectingCollection && (
<Button
type="button"
color="primary"
@@ -400,9 +600,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
{!isSelectingCollection && (
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
)}
</div>
</div>
</Modal>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,12 +4,11 @@ import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { renameItem, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { renameItem, saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import path from 'utils/common/path';
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import Help from 'components/Help';
import PathDisplay from 'components/PathDisplay';
import Portal from 'components/Portal';

View File

@@ -1,12 +1,12 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useSelector } from 'react-redux';
import Collection from './Collection';
import CreateCollection from '../CreateCollection';
import StyledWrapper from './StyledWrapper';
import CreateOrOpenCollection from './CreateOrOpenCollection';
import CollectionSearch from './CollectionSearch/index';
import { useMemo } from 'react';
import { normalizePath } from 'utils/common/path';
import { isScratchCollection } from 'utils/collections';
const Collections = ({ showSearch }) => {
const [searchText, setSearchText] = useState('');
@@ -18,10 +18,14 @@ const Collections = ({ showSearch }) => {
const workspaceCollections = useMemo(() => {
if (!activeWorkspace) return [];
return collections.filter((c) =>
activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))
);
}, [activeWorkspace, collections]);
return collections.filter((c) => {
if (isScratchCollection(c, workspaces)) {
return false;
}
return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname));
});
}, [activeWorkspace, collections, workspaces]);
if (!workspaceCollections || !workspaceCollections.length) {
return (

View File

@@ -0,0 +1,276 @@
import React, { useState, useRef } from 'react';
import { IconFileImport } from '@tabler/icons';
import { toastError } from 'utils/common/error';
import jsyaml from 'js-yaml';
import { isPostmanCollection } from 'utils/importers/postman-collection';
import { isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { isWSDLCollection } from 'utils/importers/wsdl-collection';
import { isBrunoCollection } from 'utils/importers/bruno-collection';
import { isOpenCollection } from 'utils/importers/opencollection';
import { useTheme } from 'providers/Theme';
const convertFileToObject = async (file) => {
const text = await file.text();
// Handle WSDL files - return as plain text
if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') {
return text;
}
try {
if (file.type === 'application/json' || file.name.endsWith('.json')) {
return JSON.parse(text);
}
const parsed = jsyaml.load(text);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error();
}
return parsed;
} catch {
throw new Error('Failed to parse the file ensure it is valid JSON or YAML');
}
};
const FileTab = ({
setIsLoading,
handleSubmit,
setErrorMessage
}) => {
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef(null);
const { theme } = useTheme();
const acceptedFileTypes = [
'.json',
'.yaml',
'.yml',
'.wsdl',
'.zip',
'application/json',
'application/yaml',
'application/x-yaml',
'application/zip',
'application/x-zip-compressed',
'text/xml',
'application/xml'
];
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const processZipFile = async (zipFile) => {
setIsLoading(true);
try {
const filePath = window.ipcRenderer.getFilePath(zipFile);
const isBrunoZip = await window.ipcRenderer.invoke('renderer:is-bruno-collection-zip', filePath);
if (isBrunoZip) {
const collectionName = zipFile.name.replace(/\.zip$/i, '');
await handleSubmit({ rawData: { zipFilePath: filePath, collectionName }, type: 'bruno-zip' });
return;
}
toastError(new Error('The ZIP file is not a valid Bruno collection'));
} catch (err) {
toastError(err, 'Import ZIP file failed');
} finally {
setIsLoading(false);
}
};
const handleMultipleFiles = async (fileArray) => {
setIsLoading(true);
try {
const filesData = [];
// Parse all files
for (const file of fileArray) {
try {
const data = await convertFileToObject(file);
// Determine type for each file
let type = null;
if (isOpenApiSpec(data)) {
type = 'openapi';
} else if (isWSDLCollection(data)) {
type = 'wsdl';
} else if (isPostmanCollection(data)) {
type = 'postman';
} else if (isInsomniaCollection(data)) {
type = 'insomnia';
} else if (isOpenCollection(data)) {
type = 'opencollection';
} else if (isBrunoCollection(data)) {
type = 'bruno';
}
if (type) {
filesData.push({ file, data, type });
}
} catch (err) {
console.warn(`Failed to process file ${file.name}:`, err);
}
}
if (filesData.length > 0) {
// Pass raw filesData to be processed in BulkImportCollectionLocation
handleSubmit({ filesData, type: 'multiple' });
} else {
throw new Error('No valid collections found in the selected files');
}
} catch (err) {
toastError(err, 'Import multiple files failed');
} finally {
setIsLoading(false);
}
};
const processFile = async (file) => {
setIsLoading(true);
try {
const data = await convertFileToObject(file);
if (!data) {
throw new Error('Failed to parse file content');
}
let type = null;
if (isOpenApiSpec(data)) {
type = 'openapi';
} else if (isWSDLCollection(data)) {
type = 'wsdl';
} else if (isPostmanCollection(data)) {
type = 'postman';
} else if (isInsomniaCollection(data)) {
type = 'insomnia';
} else if (isOpenCollection(data)) {
type = 'opencollection';
} else if (isBrunoCollection(data)) {
type = 'bruno';
} else {
throw new Error('Unsupported collection format');
}
await handleSubmit({ rawData: data, type });
} catch (err) {
toastError(err, 'Import collection failed');
} finally {
setIsLoading(false);
}
};
const processFiles = async (files) => {
setErrorMessage('');
const fileArray = Array.from(files);
const zipFiles = fileArray.filter((file) => file.name.endsWith('.zip'));
// If both ZIP and non-ZIP files are selected, show error
if (zipFiles.length && (fileArray.length - zipFiles.length > 0)) {
setErrorMessage('Cannot mix ZIP files with other file types. Please select either a single ZIP file OR collection files (JSON/YAML)');
return;
}
if (zipFiles.length > 1) {
setErrorMessage('Multiple ZIP files selected. Please select only one ZIP file at a time for import.');
return;
}
if (zipFiles.length) {
await processZipFile(zipFiles[0]);
return;
}
if (fileArray.length > 1) {
// Process multiple non-ZIP files normally
await handleMultipleFiles(fileArray);
} else if (fileArray.length === 1) {
await processFile(fileArray[0]);
}
};
const handleDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
await processFiles(e.dataTransfer.files);
}
};
const handleBrowseFiles = () => {
setErrorMessage('');
fileInputRef.current.click();
};
const handleFileInputChange = async (e) => {
if (e.target.files && e.target.files.length > 0) {
await processFiles(e.target.files);
e.target.value = '';
}
};
return (
<div className="mb-4">
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
${dragActive
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}
`}
>
<div className="flex flex-col items-center justify-center">
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
/>
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
onChange={handleFileInputChange}
accept={acceptedFileTypes.join(',')}
/>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-2">
Drop file(s) to import or{' '}
<button
className="underline cursor-pointer"
onClick={handleBrowseFiles}
style={{ color: theme.textLink }}
>
choose file(s)
</button>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 text-center">
Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, WSDL, and ZIP formats
</p>
</div>
</div>
</div>
);
};
export default FileTab;

View File

@@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { isGitRepositoryUrl } from 'utils/git';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
const GitHubTab = ({
handleSubmit,
setErrorMessage
}) => {
const [urlInput, setUrlInput] = useState('');
const handleGitRepositoryImport = (url) => {
if (!isGitRepositoryUrl(url)) {
setErrorMessage('Please enter a valid git repository URL');
return;
}
handleSubmit({ repositoryUrl: url, type: 'git-repository' });
};
const handleFormSubmit = (e) => {
e.preventDefault();
if (urlInput.trim()) {
handleGitRepositoryImport(urlInput.trim());
}
};
return (
<form onSubmit={handleFormSubmit}>
<div className="flex gap-2">
<input
id="gitUrlInput"
data-testid="git-url-input"
type="text"
value={urlInput}
autoFocus
onChange={(e) => setUrlInput(e.target.value)}
placeholder="Enter Git repository URL"
className="flex-1 px-3 py-1 textbox"
/>
<Button
type="submit"
id="clone-git-button"
disabled={!urlInput.trim()}
variant="filled"
color="primary"
style={{ height: '100%' }}
>
Clone
</Button>
</div>
</form>
);
};
export default GitHubTab;

View File

@@ -0,0 +1,30 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.tabs {
.tab {
padding: 6px 0px;
border: none;
border-bottom: solid 2px transparent;
margin-right: 1.25rem;
color: var(--color-tab-inactive);
cursor: pointer;
&:focus,
&:active,
&:focus-within,
&:focus-visible,
&:target {
outline: none !important;
box-shadow: none !important;
}
&.active {
color: ${(props) => props.theme.tabs.active.color} !important;
border-bottom: solid 2px ${(props) => props.theme.tabs.active.border} !important;
}
}
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isValidUrl } from 'utils/url/index';
import Button from 'ui/Button';
const UrlTab = ({
setIsLoading,
handleSubmit,
setErrorMessage
}) => {
const [urlInput, setUrlInput] = useState('');
const handleUrlImport = async (event) => {
event.preventDefault();
if (!urlInput.trim() || !isValidUrl(urlInput.trim())) {
setErrorMessage('Please enter a valid URL');
return;
}
setIsLoading(true);
try {
const { data, specType } = await fetchAndValidateApiSpecFromUrl({ url: urlInput.trim() });
// Pass raw data for all types
handleSubmit({ rawData: data, type: specType });
} catch (err) {
console.error(err);
setErrorMessage('URL import failed. Please check the URL and try again.');
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleUrlImport}>
<div className="flex gap-2">
<input
id="urlInput"
data-testid="url-input"
type="text"
value={urlInput}
autoFocus
onChange={(e) => {
setUrlInput(e.target.value);
setErrorMessage('');
}}
placeholder="Enter URL (OpenAPI/Swagger, Postman, or Insomnia specification)"
className="flex-1 px-3 py-1 textbox"
/>
<Button
type="submit"
id="import-url-button"
disabled={!urlInput.trim()}
variant="filled"
color="primary"
style={{ height: '100%' }}
>
Import
</Button>
</div>
</form>
);
};
export default UrlTab;

View File

@@ -1,176 +1,120 @@
import React, { useState, useEffect, useRef } from 'react';
import { IconFileImport } from '@tabler/icons';
import { toastError } from 'utils/common/error';
import React, { useState } from 'react';
import { IconFileImport, IconBrandGit, IconUnlink, IconX } from '@tabler/icons';
import Modal from 'components/Modal';
import jsyaml from 'js-yaml';
import { isPostmanCollection } from 'utils/importers/postman-collection';
import { isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { isWSDLCollection } from 'utils/importers/wsdl-collection';
import { isBrunoCollection } from 'utils/importers/bruno-collection';
import { isOpenCollection } from 'utils/importers/opencollection';
import classnames from 'classnames';
import StyledWrapper from './StyledWrapper';
import FileTab from './FileTab';
import GitHubTab from './GitHubTab';
import UrlTab from './UrlTab';
import FullscreenLoader from './FullscreenLoader/index';
import { useTheme } from 'providers/Theme';
const convertFileToObject = async (file) => {
const text = await file.text();
// Handle WSDL files - return as plain text
if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') {
return text;
}
try {
if (file.type === 'application/json' || file.name.endsWith('.json')) {
return JSON.parse(text);
}
const parsed = jsyaml.load(text);
if (typeof parsed !== 'object' || parsed === null) {
throw new Error();
}
return parsed;
} catch {
throw new Error('Failed to parse the file ensure it is valid JSON or YAML');
}
const IMPORT_TABS = {
FILE: 'file',
GITHUB: 'github',
URL: 'url'
};
const ImportCollection = ({ onClose, handleSubmit }) => {
const { theme } = useTheme();
const [isLoading, setIsLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef(null);
const [errorMessage, setErrorMessage] = useState('');
const [tab, setTab] = useState(IMPORT_TABS.FILE);
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
const handleTabSelect = (value) => () => {
setTab(value);
setErrorMessage('');
};
const processFile = async (file) => {
setIsLoading(true);
try {
const data = await convertFileToObject(file);
if (!data) {
throw new Error('Failed to parse file content');
}
let type = null;
if (isOpenApiSpec(data)) {
type = 'openapi';
} else if (isWSDLCollection(data)) {
type = 'wsdl';
} else if (isPostmanCollection(data)) {
type = 'postman';
} else if (isInsomniaCollection(data)) {
type = 'insomnia';
} else if (isOpenCollection(data)) {
type = 'opencollection';
} else if (isBrunoCollection(data)) {
type = 'bruno';
} else {
throw new Error('Unsupported collection format');
}
handleSubmit({ rawData: data, type });
} catch (err) {
toastError(err, 'Import collection failed');
} finally {
setIsLoading(false);
}
};
const handleDrop = async (e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
await processFile(e.dataTransfer.files[0]);
}
};
const handleBrowseFiles = () => {
fileInputRef.current.click();
};
const handleFileInputChange = async (e) => {
if (e.target.files && e.target.files[0]) {
await processFile(e.target.files[0]);
}
const getTabClassname = (tabName) => {
return classnames(`flex tab items-center py-2 px-4 ${tabName}`, {
active: tabName === tab
});
};
if (isLoading) {
return <FullscreenLoader isLoading={isLoading} />;
}
const acceptedFileTypes = [
'.json',
'.yaml',
'.yml',
'.wsdl',
'application/json',
'application/yaml',
'application/x-yaml',
'text/xml',
'application/xml'
];
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<div className="flex flex-col">
<div className="mb-4">
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
<div
onDragEnter={handleDrag}
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
${dragActive ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700'}
`}
>
<div className="flex flex-col items-center justify-center">
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
/>
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={handleFileInputChange}
accept={acceptedFileTypes.join(',')}
/>
<p className="text-gray-600 dark:text-gray-300 mb-2">
Drop file to import or{' '}
<button
className="underline cursor-pointer"
onClick={handleBrowseFiles}
style={{ color: theme.textLink }}
>
choose a file
</button>
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Supports Bruno, OpenCollection, Postman, Insomnia, OpenAPI v3, and WSDL formats
</p>
<Modal size="md" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<StyledWrapper className="flex flex-col h-full w-[600px] max-w-[600px]">
<div className="flex w-full mb-6">
<div className="flex justify-start w-full tabs">
<div
className={getTabClassname(IMPORT_TABS.FILE)}
onClick={handleTabSelect(IMPORT_TABS.FILE)}
data-testid="file-tab"
>
<IconFileImport size={18} strokeWidth={1.5} className="mr-2" />
File
</div>
<div
className={getTabClassname(IMPORT_TABS.GITHUB)}
onClick={handleTabSelect(IMPORT_TABS.GITHUB)}
data-testid="github-tab"
>
<IconBrandGit size={18} strokeWidth={1.5} className="mr-2" />
Git Repository
</div>
<div
className={getTabClassname(IMPORT_TABS.URL)}
onClick={handleTabSelect(IMPORT_TABS.URL)}
data-testid="url-tab"
>
<IconUnlink size={18} strokeWidth={1.5} className="mr-2" />
URL
</div>
</div>
</div>
</div>
{errorMessage && (
<div
className="mb-4 p-2 border rounded-md"
style={{
backgroundColor: theme.status.danger.background,
borderColor: theme.status.danger.border
}}
>
<div className="flex gap-2">
<div
className="text-xs flex-1"
style={{ color: theme.status.danger.text }}
>
{errorMessage}
</div>
<div
className="close-button flex items-center cursor-pointer"
onClick={() => setErrorMessage('')}
style={{ color: theme.status.danger.text }}
>
<IconX size={16} strokeWidth={1.5} />
</div>
</div>
</div>
)}
{tab === IMPORT_TABS.FILE && (
<FileTab
setIsLoading={setIsLoading}
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
{tab === IMPORT_TABS.GITHUB && (
<GitHubTab
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
{tab === IMPORT_TABS.URL && (
<UrlTab
setIsLoading={setIsLoading}
handleSubmit={handleSubmit}
setErrorMessage={setErrorMessage}
/>
)}
</StyledWrapper>
</Modal>
);
};

View File

@@ -43,19 +43,21 @@ const getCollectionName = (format, rawData) => {
return rawData.info?.name || 'OpenCollection';
case 'wsdl':
return 'WSDL Collection';
case 'bruno-zip':
return rawData.collectionName || 'Bruno Collection';
default:
return 'Collection';
}
};
// Convert raw data to Bruno collection format
const convertCollection = async (format, rawData, groupingType) => {
const convertCollection = async (format, rawData, groupingType, collectionFormat) => {
try {
let collection;
switch (format) {
case 'openapi':
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType });
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType, collectionFormat });
break;
case 'wsdl':
collection = await wsdlToBruno(rawData);
@@ -72,6 +74,10 @@ const convertCollection = async (format, rawData, groupingType) => {
case 'opencollection':
collection = await processOpenCollection(rawData);
break;
case 'bruno-zip':
// ZIP doesn't need conversion
collection = rawData;
break;
default:
throw new Error('Unknown collection format');
}
@@ -96,6 +102,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
const dropdownTippyRef = useRef();
const isOpenApi = format === 'openapi';
const isZipImport = format === 'bruno-zip';
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
@@ -120,7 +127,7 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
.required('Location is required')
}),
onSubmit: async (values) => {
const convertedCollection = await convertCollection(format, rawData, groupingType);
const convertedCollection = await convertCollection(format, rawData, groupingType, collectionFormat);
handleSubmit(convertedCollection, values.collectionLocation, { format: collectionFormat });
}
});
@@ -159,7 +166,19 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
}
}, [inputRef]);
const onSubmit = () => formik.handleSubmit();
const onSubmit = async () => {
if (isZipImport) {
const errors = await formik.validateForm();
if (Object.keys(errors).length > 0) {
formik.setTouched({ collectionLocation: true });
return;
}
const collectionLocation = formik.values.collectionLocation;
handleSubmit(rawData, collectionLocation, { format: collectionFormat, isZipImport: true });
} else {
formik.handleSubmit();
}
};
return (
<StyledWrapper>
@@ -212,30 +231,32 @@ const ImportCollectionLocation = ({ onClose, handleSubmit, rawData, format }) =>
</span>
</div>
<div className="mt-4">
<label htmlFor="format" className="flex items-center font-medium">
File Format
<Help width="300">
<p>Choose the file format for storing requests in this collection.</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
</label>
<select
id="format"
name="format"
className="block textbox mt-2 w-full"
value={collectionFormat}
onChange={(e) => setCollectionFormat(e.target.value)}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
{!isZipImport && (
<div className="mt-4">
<label htmlFor="format" className="flex items-center font-medium">
File Format
<Help width="300">
<p>Choose the file format for storing requests in this collection.</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
</label>
<select
id="format"
name="format"
className="block textbox mt-2 w-full"
value={collectionFormat}
onChange={(e) => setCollectionFormat(e.target.value)}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
)}
</div>
{isOpenApi && (

View File

@@ -15,14 +15,17 @@ import {
IconTerminal2
} from '@tabler/icons';
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
import { sortCollections } from 'providers/ReduxStore/slices/collections/index';
import { normalizePath } from 'utils/common/path';
import { isScratchCollection } from 'utils/collections';
import MenuDropdown from 'ui/MenuDropdown';
import ActionIcon from 'ui/ActionIcon';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation';
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
import RemoveCollectionsModal from 'components/Sidebar/Collections/RemoveCollectionsModal/index';
import CreateCollection from 'components/Sidebar/CreateCollection';
import Collections from 'components/Sidebar/Collections';
@@ -44,33 +47,50 @@ const CollectionsSection = () => {
const [createCollectionModalOpen, setCreateCollectionModalOpen] = useState(false);
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
const workspaceCollections = useMemo(() => {
if (!activeWorkspace) return [];
return collections.filter((c) =>
activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname))
);
}, [activeWorkspace, collections]);
const handleImportCollection = ({ rawData, type }) => {
return collections.filter((c) => {
if (isScratchCollection(c, workspaces)) {
return false;
}
return activeWorkspace.collections?.some((wc) => normalizePath(wc.path) === normalizePath(c.pathname));
});
}, [activeWorkspace, collections, workspaces]);
const handleImportCollection = ({ rawData, type, repositoryUrl, ...rest }) => {
setImportCollectionModalOpen(false);
setImportData({ rawData, type });
if (type === 'git-repository') {
setGitRepositoryUrl(repositoryUrl);
setShowCloneGitModal(true);
return;
}
setImportData({ rawData, type, ...rest });
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
dispatch(importCollection(convertedCollection, collectionLocation, options))
const importAction = options.isZipImport
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
: importCollection(convertedCollection, collectionLocation, options);
dispatch(importAction)
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportData(null);
toast.success('Collection imported successfully');
})
.catch((err) => {
console.error(err);
toast.error('An error occurred while importing the collection');
});
};
const handleCloseGitModal = () => {
setShowCloneGitModal(false);
setGitRepositoryUrl(null);
};
const handleToggleSearch = () => {
setShowSearch((prev) => !prev);
};
@@ -241,7 +261,7 @@ const CollectionsSection = () => {
handleSubmit={handleImportCollection}
/>
)}
{importCollectionLocationModalOpen && importData && (
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
<ImportCollectionLocation
rawData={importData.rawData}
format={importData.type}
@@ -249,6 +269,20 @@ const CollectionsSection = () => {
handleSubmit={handleImportCollectionLocation}
/>
)}
{importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && (
<BulkImportCollectionLocation
importData={importData}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>
)}
{showCloneGitModal && (
<CloneGitRepository
onClose={handleCloseGitModal}
onFinish={handleCloseGitModal}
collectionRepositoryUrl={gitRepositoryUrl}
/>
)}
<SidebarSection
id="collections"
title="Collections"

View File

@@ -169,14 +169,21 @@ class SingleLineEditor extends Component {
this.editor.setOption('theme', this.props.theme === 'dark' ? 'monokai' : 'default');
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value ?? ''));
this.editor.setCursor(cursor);
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = String(this.props.value ?? '');
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
// Update newline markers after value change
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
// Update newline markers after value change
if (this.props.showNewlineArrow) {
this._updateNewlineMarkers();
}
}
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {

View File

@@ -10,7 +10,6 @@ import Notifications from 'components/Notifications';
import Portal from 'components/Portal';
import ThemeDropdown from './ThemeDropdown';
import { openConsole } from 'providers/ReduxStore/slices/logs';
import { setActiveWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useApp } from 'providers/App';
import StyledWrapper from './StyledWrapper';
@@ -18,6 +17,7 @@ import StyledWrapper from './StyledWrapper';
const StatusBar = () => {
const dispatch = useDispatch();
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const workspaces = useSelector((state) => state.workspaces.workspaces);
const showHomePage = useSelector((state) => state.app.showHomePage);
const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
@@ -28,6 +28,8 @@ const StatusBar = () => {
const [cookiesOpen, setCookiesOpen] = useState(false);
const { version } = useApp();
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const errorCount = logs.filter((log) => log.type === 'error').length;
const handleConsoleClick = () => {
@@ -35,19 +37,15 @@ const StatusBar = () => {
};
const handlePreferencesClick = () => {
if (showHomePage || showManageWorkspacePage || showApiSpecPage || !activeTabUid) {
if (activeWorkspaceUid) {
dispatch(setActiveWorkspaceTab({ workspaceUid: activeWorkspaceUid, type: 'preferences' }));
}
} else {
dispatch(
addTab({
type: 'preferences',
uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
collectionUid: activeTab?.collectionUid
})
);
}
const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
dispatch(
addTab({
type: 'preferences',
uid: collectionUid ? `${collectionUid}-preferences` : 'preferences',
collectionUid: collectionUid
})
);
};
const openGlobalSearch = () => {

View File

@@ -4,9 +4,10 @@ import StyledWrapper from './StyledWrapper';
import SingleLineEditor from 'components/SingleLineEditor/index';
import { useTheme } from 'providers/Theme/index';
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation }) => {
const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSave, handleValidation, collectionFormat }) => {
const { displayedTheme } = useTheme();
const tagNameRegex = /^[\w-]+$/;
const isBruFormat = collectionFormat === 'bru';
const tagNameRegex = isBruFormat ? /^[\w-]+$/ : /^[\w-][\w\s-]*[\w-]$|^[\w-]+$/;
const [text, setText] = useState('');
const [error, setError] = useState('');
@@ -16,8 +17,14 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
};
const handleKeyDown = (e) => {
if (!text.trim()) {
return;
}
if (!tagNameRegex.test(text)) {
setError('Tags must only contain alpha-numeric characters, "-", "_"');
setError(isBruFormat
? 'Tags in BRU format must only contain alpha-numeric characters, "-", "_".'
: 'Tags must only contain alpha-numeric characters, spaces, "-", "_"'
);
return;
}
if (tags.includes(text)) {
@@ -28,7 +35,6 @@ const TagList = ({ tagsHintList = [], handleAddTag, tags, handleRemoveTag, onSav
const error = handleValidation(text);
if (error) {
setError(error);
setText('');
return;
}
}

View File

@@ -1,110 +0,0 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.main-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.workspace-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
position: relative;
}
.workspace-title {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
font-size: 15px;
font-weight: 600;
color: ${(props) => props.theme.text};
}
.workspace-rename-container {
height: 24px;
display: flex;
align-items: center;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
gap: 6px;
border-radius: 4px;
}
.workspace-name-input {
padding: 0 8px;
font-size: 14px;
font-weight: 600;
border-radius: 4px;
background: transparent;
color: ${(props) => props.theme.text};
outline: none;
min-width: 180px;
&:focus {
outline: none;
}
}
.inline-actions {
display: flex;
gap: 2px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
&.save {
color: ${(props) => props.theme.colors.text.green};
&:hover {
background-color: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
&:hover {
background-color: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
}
}
}
.workspace-error {
position: absolute;
top: 80%;
left: 40px;
z-index: 10;
margin-top: 4px;
padding: 4px 8px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.colors.text.danger};
border-radius: 4px;
white-space: nowrap;
}
.workspace-menu-dropdown {
min-width: 140px;
}
.tab-content {
flex: 1;
overflow: hidden;
}
`;
export default StyledWrapper;

View File

@@ -134,13 +134,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
};
const handleColorChange = (color) => {
dispatch(updateGlobalEnvironmentColor(environment.uid, color))
.then(() => {
toast.success('Environment color updated!');
})
.catch(() => {
toast.error('An error occurred while updating the environment color');
});
dispatch(updateGlobalEnvironmentColor(environment.uid, color));
};
return (

View File

@@ -13,7 +13,7 @@ import DotEnvFileDetails from 'components/Environments/DotEnvFileDetails';
import ColorBadge from 'components/ColorBadge';
import { isEqual } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import { addGlobalEnvironment, renameGlobalEnvironment, selectGlobalEnvironment, setGlobalEnvironmentDraft, clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import {
saveWorkspaceDotEnvVariables,
saveWorkspaceDotEnvRaw,
@@ -72,9 +72,22 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
dispatch(setGlobalEnvironmentDraft({
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
dispatch(clearGlobalEnvironmentDraft());
}
}, [dispatch, selectedDotEnvFile]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
setSelectedDotEnvFile(null);
handleDotEnvModifiedChange(false);
return;
}
@@ -422,7 +435,7 @@ const EnvironmentList = ({
dispatch(deleteWorkspaceDotEnvFile(workspace.uid, filename))
.then(() => {
toast.success(`${filename} file deleted!`);
setIsDotEnvModified(false);
handleDotEnvModifiedChange(false);
if (selectedDotEnvFile === filename) {
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
if (remainingFiles.length > 0) {
@@ -465,7 +478,7 @@ const EnvironmentList = ({
onSave={handleSaveDotEnv}
onSaveRaw={handleSaveDotEnvRaw}
isModified={isDotEnvModified}
setIsModified={setIsDotEnvModified}
setIsModified={handleDotEnvModifiedChange}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
/>

View File

@@ -28,7 +28,14 @@ const CollectionsList = ({ workspace }) => {
return [];
}
return workspace.collections.map((wc) => {
const filteredCollections = workspace.collections.filter((wc) => {
if (workspace.scratchTempDirectory) {
return normalizePath(wc.path) !== normalizePath(workspace.scratchTempDirectory);
}
return true;
});
return filteredCollections.map((wc) => {
const loadedCollection = collections.find(
(c) => normalizePath(c.pathname) === normalizePath(wc.path)
);
@@ -64,7 +71,7 @@ const CollectionsList = ({ workspace }) => {
}
};
});
}, [workspace.collections, collections]);
}, [workspace.collections, workspace.scratchTempDirectory, collections]);
const handleOpenCollectionClick = (collection, event) => {
if (event.target.closest('.collection-menu')) {

View File

@@ -1,11 +1,13 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconPlus, IconFolder, IconDownload } from '@tabler/icons';
import { importCollection, openCollection } from 'providers/ReduxStore/slices/collections/actions';
import { importCollection, openCollection, importCollectionFromZip } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import CreateCollection from 'components/Sidebar/CreateCollection';
import ImportCollection from 'components/Sidebar/ImportCollection';
import ImportCollectionLocation from 'components/Sidebar/ImportCollectionLocation';
import BulkImportCollectionLocation from 'components/Sidebar/BulkImportCollectionLocation';
import CloneGitRepository from 'components/Sidebar/CloneGitRespository';
import Button from 'ui/Button';
import CollectionsList from './CollectionsList';
import WorkspaceDocs from '../WorkspaceDocs';
@@ -19,6 +21,8 @@ const WorkspaceOverview = ({ workspace }) => {
const [importCollectionModalOpen, setImportCollectionModalOpen] = useState(false);
const [importCollectionLocationModalOpen, setImportCollectionLocationModalOpen] = useState(false);
const [importData, setImportData] = useState(null);
const [showCloneGitModal, setShowCloneGitModal] = useState(false);
const [gitRepositoryUrl, setGitRepositoryUrl] = useState(null);
const workspaceCollectionsCount = workspace?.collections?.length || 0;
@@ -51,25 +55,36 @@ const WorkspaceOverview = ({ workspace }) => {
setImportCollectionModalOpen(true);
};
const handleImportCollectionSubmit = ({ rawData, type }) => {
const handleImportCollectionSubmit = ({ rawData, type, repositoryUrl, ...rest }) => {
setImportCollectionModalOpen(false);
setImportData({ rawData, type });
if (type === 'git-repository') {
setGitRepositoryUrl(repositoryUrl);
setShowCloneGitModal(true);
return;
}
setImportData({ rawData, type, ...rest });
setImportCollectionLocationModalOpen(true);
};
const handleImportCollectionLocation = (convertedCollection, collectionLocation, options = {}) => {
dispatch(importCollection(convertedCollection, collectionLocation, options))
const importAction = options.isZipImport
? importCollectionFromZip(convertedCollection.zipFilePath, collectionLocation)
: importCollection(convertedCollection, collectionLocation, options);
dispatch(importAction)
.then(() => {
setImportCollectionLocationModalOpen(false);
setImportData(null);
toast.success('Collection imported successfully');
})
.catch((err) => {
console.error(err);
toast.error(err.message);
});
};
const handleCloseGitModal = () => {
setShowCloneGitModal(false);
setGitRepositoryUrl(null);
};
return (
<StyledWrapper>
{createCollectionModalOpen && (
@@ -83,7 +98,7 @@ const WorkspaceOverview = ({ workspace }) => {
/>
)}
{importCollectionLocationModalOpen && importData && (
{importCollectionLocationModalOpen && importData && (importData.type !== 'multiple' && importData.type !== 'bulk') && (
<ImportCollectionLocation
rawData={importData.rawData}
format={importData.type}
@@ -91,6 +106,20 @@ const WorkspaceOverview = ({ workspace }) => {
handleSubmit={handleImportCollectionLocation}
/>
)}
{importCollectionLocationModalOpen && importData && (importData.type === 'multiple' || importData.type === 'bulk') && (
<BulkImportCollectionLocation
importData={importData}
onClose={() => setImportCollectionLocationModalOpen(false)}
handleSubmit={handleImportCollectionLocation}
/>
)}
{showCloneGitModal && (
<CloneGitRepository
onClose={handleCloseGitModal}
onFinish={handleCloseGitModal}
collectionRepositoryUrl={gitRepositoryUrl}
/>
)}
<div className="overview-layout">
<div className="overview-main">

View File

@@ -1,262 +0,0 @@
import React, { useEffect, useState, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconCategory, IconDots, IconEdit, IconX, IconCheck, IconFolder, IconUpload } from '@tabler/icons';
import { renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import toast from 'react-hot-toast';
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
import WorkspaceOverview from './WorkspaceOverview';
import WorkspaceEnvironments from './WorkspaceEnvironments';
import Preferences from 'components/Preferences';
import WorkspaceTabs from 'components/WorkspaceTabs';
import StyledWrapper from './StyledWrapper';
import Dropdown from 'components/Dropdown';
import { getRevealInFolderLabel } from 'utils/common/platform';
import { getWorkspaceDisplayName } from 'components/AppTitleBar';
import classNames from 'classnames';
const WorkspaceHome = () => {
const dispatch = useDispatch();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const workspaceTabs = useSelector((state) => state.workspaceTabs);
const activeTabUid = workspaceTabs.activeTabUid;
const activeTab = workspaceTabs.tabs.find((t) => t.uid === activeTabUid);
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
const [workspaceNameError, setWorkspaceNameError] = useState('');
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
const workspaceNameInputRef = useRef(null);
const workspaceRenameContainerRef = useRef(null);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
useEffect(() => {
if (!isRenamingWorkspace) return;
const handleClickOutside = (event) => {
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
handleCancelWorkspaceRename();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isRenamingWorkspace]);
if (!activeWorkspace) {
return null;
}
const handleRenameWorkspaceClick = () => {
dropdownTippyRef.current?.hide();
setIsRenamingWorkspace(true);
setWorkspaceNameInput(activeWorkspace.name);
setWorkspaceNameError('');
setTimeout(() => {
workspaceNameInputRef.current?.focus();
workspaceNameInputRef.current?.select();
}, 50);
};
const handleCloseWorkspaceClick = () => {
dropdownTippyRef.current?.hide();
if (activeWorkspace.type === 'default') {
toast.error('Cannot close the default workspace');
return;
}
setCloseWorkspaceModalOpen(true);
};
const handleShowInFolder = () => {
dropdownTippyRef.current?.hide();
if (activeWorkspace.pathname) {
dispatch(showInFolder(activeWorkspace.pathname)).catch((error) => {
toast.error('Error opening the folder');
});
}
};
const handleExportWorkspace = () => {
dropdownTippyRef.current?.hide();
dispatch(exportWorkspaceAction(activeWorkspace.uid))
.then((result) => {
if (!result.canceled) {
toast.success('Workspace exported successfully');
}
})
.catch((error) => {
toast.error(error?.message || 'Error exporting workspace');
});
};
const validateWorkspaceName = (name) => {
if (!name || name.trim() === '') {
return 'Name is required';
}
if (name.length < 1) {
return 'Must be at least 1 character';
}
if (name.length > 255) {
return 'Must be 255 characters or less';
}
return null;
};
const handleSaveWorkspaceRename = () => {
const error = validateWorkspaceName(workspaceNameInput);
if (error) {
setWorkspaceNameError(error);
return;
}
dispatch(renameWorkspaceAction(activeWorkspace.uid, workspaceNameInput))
.then(() => {
toast.success('Workspace renamed!');
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while renaming the workspace');
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
});
};
const handleCancelWorkspaceRename = () => {
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
};
const handleWorkspaceNameChange = (e) => {
setWorkspaceNameInput(e.target.value);
if (workspaceNameError) {
setWorkspaceNameError('');
}
};
const handleWorkspaceNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveWorkspaceRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelWorkspaceRename();
}
};
const renderTabContent = () => {
if (!activeTab) return null;
switch (activeTab.type) {
case 'overview':
return <WorkspaceOverview workspace={activeWorkspace} />;
case 'environments':
return <WorkspaceEnvironments workspace={activeWorkspace} />;
case 'preferences':
return <Preferences />;
default:
return null;
}
};
return (
<StyledWrapper className="h-full">
<div className="h-full flex flex-row">
{closeWorkspaceModalOpen && (
<CloseWorkspace
workspaceUid={activeWorkspace.uid}
onClose={() => setCloseWorkspaceModalOpen(false)}
/>
)}
<div className="main-content">
<div className="workspace-header">
<div className="workspace-title">
<IconCategory size={20} strokeWidth={1.5} />
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
</div>
) : (
<span className={classNames('workspace-name', { 'italic text-muted': !activeWorkspace?.name })}>{getWorkspaceDisplayName(activeWorkspace.name)}</span>
)}
</div>
{!isRenamingWorkspace && activeWorkspace.type !== 'default' && (
<Dropdown
style="new"
placement="bottom-end"
onCreate={onDropdownCreate}
icon={<IconDots size={18} strokeWidth={1.5} className="cursor-pointer" />}
>
<div className="workspace-menu-dropdown">
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
<IconEdit size={16} strokeWidth={1.5} />
<span>Rename</span>
</div>
<div className="dropdown-item" onClick={handleShowInFolder}>
<IconFolder size={16} strokeWidth={1.5} />
<span>{getRevealInFolderLabel()}</span>
</div>
<div className="dropdown-item" onClick={handleExportWorkspace}>
<IconUpload size={16} strokeWidth={1.5} />
<span>Export</span>
</div>
<div className="dropdown-item" onClick={handleCloseWorkspaceClick}>
<IconX size={16} strokeWidth={1.5} />
<span>Close</span>
</div>
</div>
</Dropdown>
)}
{workspaceNameError && isRenamingWorkspace && (
<div className="workspace-error">{workspaceNameError}</div>
)}
</div>
<WorkspaceTabs workspaceUid={activeWorkspace.uid} />
<div className="tab-content">{renderTabContent()}</div>
</div>
</div>
</StyledWrapper>
);
};
export default WorkspaceHome;

View File

@@ -1,197 +0,0 @@
import styled from 'styled-components';
const Wrapper = styled.div`
position: relative;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: ${(props) => props.theme.requestTabs.bottomBorder};
z-index: 0;
}
.tabs-scroll-container {
overflow-x: auto;
overflow-y: clip;
padding-bottom: 10px;
margin-bottom: -10px;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
ul {
margin-bottom: 0;
overflow: visible;
}
}
ul {
padding: 0 3px;
margin: 0;
display: flex;
align-items: flex-end;
position: relative;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
li {
display: inline-flex;
max-width: 180px;
min-width: 80px;
list-style: none;
cursor: pointer;
font-size: 0.8125rem;
position: relative;
margin-right: 3px;
color: ${(props) => props.theme.requestTabs.color};
background: transparent;
border: 1px solid transparent;
padding: 6px 0;
flex-shrink: 0;
margin-bottom: 3px;
.tab-container {
width: 100%;
position: relative;
overflow: hidden;
}
&:not(.active) {
background: ${(props) => props.theme.requestTabs.bg};
border-color: transparent;
border-radius: ${(props) => props.theme.border.radius.base};
}
&:nth-last-child(1) {
margin-right: 4px;
}
&.has-overflow:not(:hover) .tab-name {
mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 12px),
transparent 100%
);
}
&.has-overflow:hover .tab-name {
mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to right,
${(props) => props.theme.requestTabs.color} 0%,
${(props) => props.theme.requestTabs.color} calc(100% - 8px),
transparent 100%
);
}
&.active {
background: ${(props) => props.theme.bg || '#ffffff'};
border: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom-color: ${(props) => props.theme.bg || '#ffffff'};
border-radius: 8px 8px 0 0;
z-index: 2;
margin-bottom: -2px;
padding-bottom: 12px;
&::before {
content: '';
position: absolute;
bottom: 1px;
left: -8px;
width: 8px;
height: 8px;
background: transparent;
border-bottom-right-radius: 6px;
box-shadow: 3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-right: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
}
&::after {
content: '';
position: absolute;
bottom: 1px;
right: -8px;
width: 8px;
height: 8px;
background: transparent;
border-bottom-left-radius: 6px;
box-shadow: -3px 3px 0 0 ${(props) => props.theme.bg || '#ffffff'};
border-left: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
border-bottom: 1px solid ${(props) => props.theme.requestTabs.bottomBorder};
}
}
&.permanent-tab {
.close-icon {
display: none;
}
}
&.short-tab {
width: 32px;
min-width: 32px;
max-width: 32px;
padding: 5px 0;
display: inline-flex;
justify-content: center;
align-items: center;
color: ${(props) => props.theme.text};
background-color: transparent;
border: 1px solid transparent;
border-radius: ${(props) => props.theme.border.radius.base};
flex-shrink: 0;
> div {
padding: 3px;
display: flex;
align-items: center;
justify-content: center;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: background-color 0.12s ease, color 0.12s ease;
}
svg {
height: 20px;
width: 20px;
}
&:hover {
> div {
background-color: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
}
}
}
}
&.has-chevrons ul {
padding-left: 0;
}
`;
export default Wrapper;

View File

@@ -1,61 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
position: relative;
width: 100%;
height: 100%;
.tab-label {
overflow: hidden;
align-items: center;
position: relative;
flex: 1;
min-width: 0;
}
.tab-icon {
flex-shrink: 0;
display: flex;
align-items: center;
margin-right: 6px;
color: ${(props) => props.theme.requestTabs.color};
}
.tab-name {
position: relative;
overflow: hidden;
white-space: nowrap;
font-size: 0.8125rem;
padding-right: 2px;
}
.close-icon {
margin-left: 6px;
padding: 2px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s, background-color 0.15s;
&:hover {
background-color: ${(props) => props.theme.requestTabs.closeIconHoverBg || 'rgba(0, 0, 0, 0.1)'};
}
svg {
width: 14px;
height: 14px;
}
}
&:hover .close-icon {
opacity: 1;
}
&.permanent .close-icon {
display: none;
}
`;
export default StyledWrapper;

View File

@@ -1,45 +0,0 @@
import React from 'react';
import { IconX, IconHome, IconWorld, IconSettings } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
import StyledWrapper from './StyledWrapper';
const TAB_ICONS = {
overview: IconHome,
environments: IconWorld,
preferences: IconSettings
};
const WorkspaceTab = ({ tab, isActive }) => {
const dispatch = useDispatch();
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();
dispatch(closeWorkspaceTab({ uid: tab.uid }));
};
const TabIcon = TAB_ICONS[tab.type];
return (
<StyledWrapper className={`flex items-center justify-between tab-container px-2 ${tab.permanent ? 'permanent' : ''}`}>
<div className="flex items-center tab-label">
{TabIcon && (
<span className="tab-icon">
<TabIcon size={14} strokeWidth={1.5} />
</span>
)}
<span className="tab-name" title={tab.label}>
{tab.label}
</span>
</div>
{!tab.permanent && (
<div className="close-icon" onClick={handleCloseClick}>
<IconX size={14} strokeWidth={1.5} />
</div>
)}
</StyledWrapper>
);
};
export default WorkspaceTab;

View File

@@ -1,158 +0,0 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import filter from 'lodash/filter';
import classnames from 'classnames';
import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { focusWorkspaceTab, initializeWorkspaceTabs } from 'providers/ReduxStore/slices/workspaceTabs';
import WorkspaceTab from './WorkspaceTab';
import StyledWrapper from './StyledWrapper';
const PERMANENT_TABS = [
{ type: 'overview', label: 'Overview' },
{ type: 'environments', label: 'Global Environments' }
];
const WorkspaceTabs = ({ workspaceUid }) => {
const dispatch = useDispatch();
const tabsRef = useRef();
const scrollContainerRef = useRef();
const [tabOverflowStates, setTabOverflowStates] = useState({});
const [showChevrons, setShowChevrons] = useState(false);
const tabs = useSelector((state) => state.workspaceTabs.tabs);
const activeTabUid = useSelector((state) => state.workspaceTabs.activeTabUid);
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
// Initialize permanent tabs for this workspace
useEffect(() => {
if (workspaceUid) {
dispatch(initializeWorkspaceTabs({
workspaceUid,
permanentTabs: PERMANENT_TABS
}));
}
}, [workspaceUid, dispatch]);
const createSetHasOverflow = useCallback((tabUid) => {
return (hasOverflow) => {
setTabOverflowStates((prev) => {
if (prev[tabUid] === hasOverflow) {
return prev;
}
return {
...prev,
[tabUid]: hasOverflow
};
});
};
}, []);
// Filter tabs for this workspace
const workspaceTabs = filter(tabs, (t) => t.workspaceUid === workspaceUid);
useEffect(() => {
if (!activeTabUid) return;
const checkOverflow = () => {
if (tabsRef.current && scrollContainerRef.current) {
const hasOverflow = tabsRef.current.scrollWidth > scrollContainerRef.current.clientWidth + 1;
setShowChevrons(hasOverflow);
}
};
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
if (scrollContainerRef.current) {
resizeObserver.observe(scrollContainerRef.current);
}
return () => resizeObserver.disconnect();
}, [activeTabUid, workspaceTabs.length, screenWidth, leftSidebarWidth, sidebarCollapsed]);
const getTabClassname = (tab, index) => {
return classnames('request-tab select-none', {
'active': tab.uid === activeTabUid,
'permanent-tab': tab.permanent,
'last-tab': workspaceTabs && workspaceTabs.length && index === workspaceTabs.length - 1,
'has-overflow': tabOverflowStates[tab.uid]
});
};
const handleClick = (tab) => {
dispatch(focusWorkspaceTab({ uid: tab.uid }));
};
if (!workspaceUid || workspaceTabs.length === 0) {
return null;
}
const effectiveSidebarWidth = sidebarCollapsed ? 0 : leftSidebarWidth;
const maxTablistWidth = screenWidth - effectiveSidebarWidth - 150;
const leftSlide = () => {
scrollContainerRef.current?.scrollBy({
left: -120,
behavior: 'smooth'
});
};
const rightSlide = () => {
scrollContainerRef.current?.scrollBy({
left: 120,
behavior: 'smooth'
});
};
const getRootClassname = () => {
return classnames({
'has-chevrons': showChevrons
});
};
return (
<StyledWrapper className={getRootClassname()}>
<div className="flex items-center pl-2">
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={leftSlide}>
<div className="flex items-center">
<IconChevronLeft size={18} strokeWidth={1.5} />
</div>
</li>
) : null}
</ul>
<div className="tabs-scroll-container" style={{ maxWidth: maxTablistWidth }} ref={scrollContainerRef}>
<ul role="tablist" ref={tabsRef}>
{workspaceTabs.map((tab, index) => (
<li
key={tab.uid}
className={getTabClassname(tab, index)}
onClick={() => handleClick(tab)}
>
<WorkspaceTab
tab={tab}
isActive={tab.uid === activeTabUid}
hasOverflow={tabOverflowStates[tab.uid]}
setHasOverflow={createSetHasOverflow(tab.uid)}
/>
</li>
))}
</ul>
</div>
<ul role="tablist">
{showChevrons ? (
<li className="select-none short-tab" onClick={rightSlide}>
<div className="flex items-center">
<IconChevronRight size={18} strokeWidth={1.5} />
</div>
</li>
) : null}
</ul>
</div>
</StyledWrapper>
);
};
export default WorkspaceTabs;

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { isItemAFolder } from 'utils/collections';
import { sortByNameThenSequence } from 'utils/common/index';
import filter from 'lodash/filter';
@@ -63,6 +63,7 @@ const useCollectionFolderTree = (collectionUid) => {
const collection = useSelector((state) => findCollectionByUid(state.collections.collections, collectionUid));
const [currentFolderPath, setCurrentFolderPath] = useState([]);
const [selectedFolderUid, setSelectedFolderUid] = useState(null);
const tree = useMemo(() => {
if (!collection || !collection.items) {
return {};
@@ -143,6 +144,10 @@ const useCollectionFolderTree = (collectionUid) => {
setSelectedFolderUid(null);
}, []);
useEffect(() => {
reset();
}, [collectionUid, reset]);
return {
currentFolders,
breadcrumbs,

View File

@@ -50,7 +50,7 @@ export default function useProtoFileManagement(collection) {
const cachedMethods = protofileCache[absolutePath];
if (cachedMethods && !isLoadingMethods && !isManualRefresh) {
return { methods: cachedMethods, error: null };
return { methods: cachedMethods, error: null, fromCache: true };
}
setIsLoadingMethods(true);
@@ -67,7 +67,7 @@ export default function useProtoFileManagement(collection) {
[absolutePath]: methods
}));
return { methods, error: null };
return { methods, error: null, fromCache: false };
} catch (err) {
console.error('Error loading gRPC methods:', err);
return { methods: [], error: err };

View File

@@ -27,7 +27,7 @@ export default function useReflectionManagement(item, collectionUid) {
const cachedMethods = reflectionCache[url];
if (!isManualRefresh && cachedMethods && !isLoadingMethods) {
return { methods: cachedMethods, error: null };
return { methods: cachedMethods, error: null, fromCache: true };
}
setIsLoadingMethods(true);
@@ -44,7 +44,7 @@ export default function useReflectionManagement(item, collectionUid) {
[url]: methods
}));
return { methods, error: null };
return { methods, error: null, fromCache: false };
} catch (error) {
console.error('Error loading gRPC methods:', error);
return { methods: [], error };

View File

@@ -1,6 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import classnames from 'classnames';
import WorkspaceHome from 'components/WorkspaceHome';
import ManageWorkspace from 'components/ManageWorkspace';
import RequestTabs from 'components/RequestTabs';
import RequestTabPanel from 'components/RequestTabPanel';
@@ -77,7 +76,6 @@ export default function Main() {
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeApiSpecUid = useSelector((state) => state.apiSpec.activeApiSpecUid);
const isDragging = useSelector((state) => state.app.isDragging);
const showHomePage = useSelector((state) => state.app.showHomePage);
const showApiSpecPage = useSelector((state) => state.app.showApiSpecPage);
const showManageWorkspacePage = useSelector((state) => state.app.showManageWorkspacePage);
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
@@ -144,8 +142,6 @@ export default function Main() {
<ApiSpecPanel key={activeApiSpecUid} />
) : showManageWorkspacePage ? (
<ManageWorkspace />
) : showHomePage || !activeTabUid ? (
<WorkspaceHome />
) : (
<>
<RequestTabs />

View File

@@ -62,7 +62,6 @@ const SaveRequestsModal = ({ onClose }) => {
const requests = filter(items, (item) => isItemARequest(item) && hasRequestChanges(item));
each(requests, (draft) => {
requestDrafts.push({
type: 'request',
...draft,
collectionUid: collectionUid
});
@@ -116,7 +115,7 @@ const SaveRequestsModal = ({ onClose }) => {
// Separate drafts by type
const collectionDrafts = allDrafts.filter((d) => d.type === 'collection');
const folderDrafts = allDrafts.filter((d) => d.type === 'folder');
const requestDrafts = allDrafts.filter((d) => d.type === 'request');
const requestDrafts = allDrafts.filter((d) => isItemARequest(d));
const collectionEnvironmentDrafts = allDrafts.filter((d) => d.type === 'collection-environment');
const globalEnvironmentDrafts = allDrafts.filter((d) => d.type === 'global-environment');

View File

@@ -1,14 +1,12 @@
import { useEffect } from 'react';
import {
updateCookies,
updatePreferences
updatePreferences,
setGitVersion
} from 'providers/ReduxStore/slices/app';
import {
addTab
} from 'providers/ReduxStore/slices/tabs';
import {
setActiveWorkspaceTab
} from 'providers/ReduxStore/slices/workspaceTabs';
import {
brunoConfigUpdateEvent,
collectionAddDirectoryEvent,
@@ -28,7 +26,10 @@ import {
setDotEnvVariables
} from 'providers/ReduxStore/slices/collections';
import { collectionAddEnvFileEvent, openCollectionEvent, hydrateCollectionWithUiStateSnapshot, mergeAndPersistEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { workspaceOpenedEvent, workspaceConfigUpdatedEvent } from 'providers/ReduxStore/slices/workspaces/actions';
import {
workspaceOpenedEvent,
workspaceConfigUpdatedEvent
} from 'providers/ReduxStore/slices/workspaces/actions';
import { workspaceDotEnvUpdateEvent, setWorkspaceDotEnvVariables } from 'providers/ReduxStore/slices/workspaces';
import toast from 'react-hot-toast';
import { useDispatch, useStore } from 'react-redux';
@@ -274,24 +275,21 @@ const useIpcEvents = () => {
const removeShowPreferencesListener = ipcRenderer.on('main:open-preferences', () => {
const state = store.getState();
const activeWorkspaceUid = state.workspaces?.activeWorkspaceUid;
const { showHomePage, showManageWorkspacePage, showApiSpecPage } = state.app;
const workspaces = state.workspaces?.workspaces;
const tabs = state.tabs?.tabs;
const activeTabUid = state.tabs?.activeTabUid;
const activeTab = tabs?.find((t) => t.uid === activeTabUid);
if (showHomePage || showManageWorkspacePage || showApiSpecPage || !activeTabUid) {
if (activeWorkspaceUid) {
dispatch(setActiveWorkspaceTab({ workspaceUid: activeWorkspaceUid, type: 'preferences' }));
}
} else {
dispatch(
addTab({
type: 'preferences',
uid: activeTab?.collectionUid ? `${activeTab.collectionUid}-preferences` : 'preferences',
collectionUid: activeTab?.collectionUid
})
);
}
const activeWorkspace = workspaces?.find((w) => w.uid === activeWorkspaceUid);
const collectionUid = activeTab?.collectionUid || activeWorkspace?.scratchCollectionUid;
dispatch(
addTab({
type: 'preferences',
uid: collectionUid ? `${collectionUid}-preferences` : 'preferences',
collectionUid
})
);
});
const removePreferencesUpdatesListener = ipcRenderer.on('main:load-preferences', (val) => {
@@ -332,6 +330,10 @@ const useIpcEvents = () => {
dispatch(updateCollectionLoadingState(val));
});
const gitVersionListener = ipcRenderer.on('main:git-version', (val) => {
dispatch(setGitVersion(val));
});
return () => {
removeCollectionTreeUpdateListener();
removeApiSpecTreeUpdateListener();
@@ -363,6 +365,7 @@ const useIpcEvents = () => {
removeCollectionLoadingStateListener();
removePersistentEnvVariablesUpdateListener();
removeSystemResourcesListener();
gitVersionListener();
};
}, [isElectron]);
};

View File

@@ -11,11 +11,11 @@ import {
saveRequest,
saveCollectionRoot,
saveFolderRoot,
saveCollectionSettings
saveCollectionSettings,
closeTabs
} from 'providers/ReduxStore/slices/collections/actions';
import { findCollectionByUid, findItemInCollection } from 'utils/collections';
import { addTab, closeTabs, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { closeWorkspaceTab } from 'providers/ReduxStore/slices/workspaceTabs';
import { addTab, reorderTabs, switchTab } from 'providers/ReduxStore/slices/tabs';
import { toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { getKeyBindingsForActionAllOS } from './keyMappings';
@@ -26,8 +26,6 @@ export const HotkeysProvider = (props) => {
const tabs = useSelector((state) => state.tabs.tabs);
const collections = useSelector((state) => state.collections.collections);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const showHomePage = useSelector((state) => state.app.showHomePage);
const activeWorkspaceTabUid = useSelector((state) => state.workspaceTabs.activeTabUid);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showGlobalSearchModal, setShowGlobalSearchModal] = useState(false);
@@ -174,9 +172,7 @@ export const HotkeysProvider = (props) => {
// close tab hotkey
useEffect(() => {
Mousetrap.bind([...getKeyBindingsForActionAllOS('closeTab')], (e) => {
if (showHomePage && activeWorkspaceTabUid) {
dispatch(closeWorkspaceTab({ uid: activeWorkspaceTabUid }));
} else if (activeTabUid) {
if (activeTabUid) {
dispatch(
closeTabs({
tabUids: [activeTabUid]
@@ -190,7 +186,7 @@ export const HotkeysProvider = (props) => {
return () => {
Mousetrap.unbind([...getKeyBindingsForActionAllOS('closeTab')]);
};
}, [activeTabUid, showHomePage, activeWorkspaceTabUid]);
}, [activeTabUid]);
// Switch to the previous tab
useEffect(() => {

View File

@@ -4,7 +4,6 @@ import debugMiddleware from './middlewares/debug/middleware';
import appReducer from './slices/app';
import collectionsReducer from './slices/collections';
import tabsReducer from './slices/tabs';
import workspaceTabsReducer from './slices/workspaceTabs';
import notificationsReducer from './slices/notifications';
import globalEnvironmentsReducer from './slices/global-environments';
import logsReducer from './slices/logs';
@@ -28,7 +27,6 @@ export const store = configureStore({
app: appReducer,
collections: collectionsReducer,
tabs: tabsReducer,
workspaceTabs: workspaceTabsReducer,
notifications: notificationsReducer,
globalEnvironments: globalEnvironmentsReducer,
logs: logsReducer,

View File

@@ -3,9 +3,9 @@ import each from 'lodash/each';
import filter from 'lodash/filter';
import { createListenerMiddleware } from '@reduxjs/toolkit';
import { removeTaskFromQueue } from 'providers/ReduxStore/slices/app';
import { addTab, closeTabs, closeAllCollectionTabs } from 'providers/ReduxStore/slices/tabs';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { collectionAddFileEvent, collectionChangeFileEvent } from 'providers/ReduxStore/slices/collections';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid, findItemInCollection, flattenItems } from 'utils/collections/index';
import { findCollectionByUid, findItemInCollectionByPathname, getDefaultRequestPaneTab, findItemInCollectionByItemUid } from 'utils/collections/index';
import { taskTypes } from './utils';
const taskMiddleware = createListenerMiddleware();
@@ -29,14 +29,13 @@ taskMiddleware.startListening({
const collection = findCollectionByUid(state.collections.collections, collectionUid);
if (collection && collection.mountStatus === 'mounted' && !collection.isLoading) {
const item = findItemInCollectionByPathname(collection, task.itemPathname);
const isTransient = item?.isTransient ?? false;
if (item) {
listenerApi.dispatch(
addTab({
uid: item.uid,
collectionUid: collection.uid,
requestPaneTab: getDefaultRequestPaneTab(item),
preview: !isTransient
preview: task?.preview ?? true
})
);
}
@@ -93,39 +92,4 @@ taskMiddleware.startListening({
}
});
/*
* When tabs are closed, check if any of them are transient requests.
* If so, delete the temporary files from the filesystem.
* Note: If a transient request was saved (moved to permanent location),
* the file will already be deleted, which is expected behavior.
*/
taskMiddleware.startListening({
actionCreator: closeTabs,
effect: (action, listenerApi) => {
const state = listenerApi.getState();
const tabUids = action.payload.tabUids || [];
const { ipcRenderer } = window;
each(tabUids, (tabUid) => {
const collections = state.collections.collections;
for (const collection of collections) {
const item = findItemInCollection(collection, tabUid);
const isTransient = item?.isTransient ?? false;
if (item && isTransient) {
ipcRenderer
.invoke('renderer:delete-item', item.pathname, item.type, collection.pathname)
.then(() => {})
.catch((err) => {
if (err.message && !err.message.includes('does not exist')) {
console.error(`Failed to delete transient request file: ${item.pathname}`, err);
}
});
break;
}
}
});
}
});
export default taskMiddleware;

View File

@@ -1,7 +1,7 @@
import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter';
import brunoClipboard from 'utils/bruno-clipboard';
import { addTab, focusTab, closeTabs } from './tabs';
import { addTab, focusTab } from './tabs';
const initialState = {
isDragging: false,
@@ -48,6 +48,8 @@ const initialState = {
},
cookies: [],
taskQueue: [],
gitOperationProgress: {},
gitVersion: null,
clipboard: {
hasCopiedItems: false // Whether clipboard has Bruno data (for UI)
},
@@ -123,6 +125,19 @@ export const appSlice = createSlice({
toggleSidebarCollapse: (state) => {
state.sidebarCollapsed = !state.sidebarCollapsed;
},
updateGitOperationProgress: (state, action) => {
const { uid, data } = action.payload;
if (!state.gitOperationProgress[uid]) {
state.gitOperationProgress[uid] = { progressData: [] };
}
state.gitOperationProgress[uid].progressData.push(data);
},
removeGitOperationProgress: (state, action) => {
delete state.gitOperationProgress[action.payload];
},
setGitVersion: (state, action) => {
state.gitVersion = action.payload;
},
setClipboard: (state, action) => {
// Update clipboard UI state
state.clipboard.hasCopiedItems = action.payload.hasCopiedItems;
@@ -164,6 +179,9 @@ export const {
updateSystemProxyVariables,
updateGenerateCode,
toggleSidebarCollapse,
updateGitOperationProgress,
removeGitOperationProgress,
setGitVersion,
setClipboard
} = appSlice.actions;

View File

@@ -10,6 +10,7 @@ import trim from 'lodash/trim';
import path, { normalizePath } from 'utils/common/path';
import { insertTaskIntoQueue, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import IpcErrorModal from 'components/Errors/IpcErrorModal/index';
import {
findCollectionByUid,
findEnvironmentInCollection,
@@ -64,7 +65,7 @@ import {
} from './index';
import { each } from 'lodash';
import { closeAllCollectionTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { closeAllCollectionTabs, closeTabs as _closeTabs, updateResponsePaneScrollPosition } from 'providers/ReduxStore/slices/tabs';
import { removeCollectionFromWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { resolveRequestFilename } from 'utils/common/platform';
import { interpolateUrl, parsePathParams, splitOnFirst } from 'utils/url/index';
@@ -1338,7 +1339,8 @@ export const newHttpRequest = (params) => (dispatch, getState) => {
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
itemPathname: fullName
itemPathname: fullName,
preview: false
})
);
resolve();
@@ -1494,7 +1496,8 @@ export const newGrpcRequest = (params) => (dispatch, getState) => {
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
itemPathname: fullName
itemPathname: fullName,
preview: false
})
);
resolve();
@@ -1621,7 +1624,8 @@ export const newWsRequest = (params) => (dispatch, getState) => {
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid,
itemPathname: fullName
itemPathname: fullName,
preview: false
})
);
resolve();
@@ -1770,7 +1774,7 @@ export const addEnvironment = (name, collectionUid) => (dispatch, getState) => {
});
};
export const importEnvironment = ({ name, variables, collectionUid }) => (dispatch, getState) => {
export const importEnvironment = ({ name, variables, color, collectionUid }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const state = getState();
const collection = findCollectionByUid(state.collections.collections, collectionUid);
@@ -1782,7 +1786,7 @@ export const importEnvironment = ({ name, variables, collectionUid }) => (dispat
const { ipcRenderer } = window;
ipcRenderer
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables)
.invoke('renderer:create-environment', collection.pathname, sanitizedName, variables, color)
.then(
dispatch(
updateLastAction({
@@ -2436,6 +2440,53 @@ export const updateBrunoConfig = (brunoConfig, collectionUid) => (dispatch, getS
});
};
/**
* Opens a scratch collection and creates it in Redux state.
* This is a simplified version of openCollectionEvent for scratch collections,
* without workspace management, toasts, or sidebar toggles.
*
* @param {string} uid - The unique identifier for the scratch collection
* @param {string} pathname - The filesystem path to the scratch collection
* @param {Object} brunoConfig - The Bruno configuration object for the collection
* @returns {Promise} Resolves when the collection is created, rejects on error
*/
export const openScratchCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
const state = getState();
const existingCollection = state.collections.collections.find(
(c) => normalizePath(c.pathname) === normalizePath(pathname)
);
if (existingCollection) {
resolve();
return;
}
const collection = {
version: '1',
uid,
name: brunoConfig.name,
pathname,
items: [],
runtimeVariables: {},
brunoConfig
};
ipcRenderer
.invoke('renderer:get-collection-security-config', pathname)
.then((securityConfig) => {
collectionSchema
.validate(collection)
.then(() => dispatch(_createCollection({ ...collection, securityConfig })))
.then(resolve)
.catch(reject);
})
.catch(reject);
});
};
export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, getState) => {
const { ipcRenderer } = window;
@@ -2444,24 +2495,20 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
const workspaceProcessEnvVariables = activeWorkspace?.processEnvVariables || {};
// Check if collection already exists in Redux state
const existingCollection = state.collections.collections.find(
(c) => normalizePath(c.pathname) === normalizePath(pathname)
);
// Check if collection is already in the current workspace
const isAlreadyInWorkspace = activeWorkspace?.collections?.some(
(c) => normalizePath(c.path) === normalizePath(pathname)
);
// If collection already exists in Redux AND in current workspace, show toast and return
if (existingCollection && isAlreadyInWorkspace) {
toast.success('Collection is already opened');
resolve();
return;
}
// If collection exists in Redux but not in workspace, add to workspace
if (existingCollection) {
if (state.app.sidebarCollapsed) {
dispatch(toggleSidebarCollapse());
@@ -2490,7 +2537,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
return;
}
// Collection doesn't exist - create it
const collection = {
version: '1',
uid: uid,
@@ -2517,7 +2563,6 @@ export const openCollectionEvent = (uid, pathname, brunoConfig) => (dispatch, ge
);
if (currentWorkspace) {
// Set collection-workspace mapping for workspace env vars
ipcRenderer.invoke('renderer:set-collection-workspace', uid, currentWorkspace.pathname);
const alreadyInWorkspace = currentWorkspace.collections?.some(
@@ -2641,25 +2686,46 @@ export const importCollection = (collection, collectionLocation, options = {}) =
try {
const state = getState();
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
const isMultiple = Array.isArray(collection);
const collectionPath = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
const result = await ipcRenderer.invoke('renderer:import-collection', collection, collectionLocation, options.format || DEFAULT_COLLECTION_FORMAT);
const importedPaths = result.success.items;
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
const workspaceCollection = {
name: collection.name,
path: collectionPath
};
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection);
if (importedPaths.length > 0 && activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
for (const importedItem of importedPaths) {
const workspaceCollection = {
name: importedItem.name,
path: importedItem.path
};
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, workspaceCollection);
}
}
resolve(collectionPath);
resolve(isMultiple ? importedPaths : importedPaths[0]);
} catch (error) {
reject(error);
}
});
};
export const importCollectionFromZip = (zipFilePath, collectionLocation) => async (dispatch, getState) => {
const { ipcRenderer } = window;
const state = getState();
const activeWorkspace = state.workspaces.workspaces.find((w) => w.uid === state.workspaces.activeWorkspaceUid);
const collectionPath = await ipcRenderer.invoke('renderer:import-collection-zip', zipFilePath, collectionLocation);
if (activeWorkspace && activeWorkspace.pathname && activeWorkspace.type !== 'default') {
const collectionName = path.basename(collectionPath);
await ipcRenderer.invoke('renderer:add-collection-to-workspace', activeWorkspace.pathname, {
name: collectionName,
path: collectionPath
});
}
return collectionPath;
};
export const moveCollectionAndPersist
= ({ draggedItem, targetItem }) =>
(dispatch, getState) => {
@@ -2964,3 +3030,75 @@ export const deleteDotEnvFile = (collectionUid, filename = '.env') => (dispatch,
.catch(reject);
});
};
export const cloneGitRepository = (data) => (dispatch, getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:clone-git-repository', data)
.then((res) => {
console.log('clone done', res);
})
.then(resolve)
.catch((err) => {
toast.custom(<IpcErrorModal error={err?.message} />);
reject();
});
});
};
export const scanForBrunoFiles = (dir) => (dispatch, getState) => {
const { ipcRenderer } = window;
return new Promise((resolve, reject) => {
ipcRenderer
.invoke('renderer:scan-for-bruno-files', dir)
.then(resolve)
.catch((err) => {
reject();
});
});
};
/**
* Close tabs and delete any transient request files from the filesystem.
* This thunk wraps the closeTabs reducer to handle transient file cleanup automatically.
*/
export const closeTabs = ({ tabUids }) => async (dispatch, getState) => {
const { ipcRenderer } = window;
const state = getState();
const collections = state.collections.collections;
const tempDirectories = state.collections.tempDirectories || {};
// Find transient items and group by temp directory before closing tabs
const transientByTempDir = {};
each(tabUids, (tabUid) => {
for (const collection of collections) {
const item = findItemInCollection(collection, tabUid);
if (item?.isTransient && item.pathname) {
const tempDir = tempDirectories[collection.uid];
if (tempDir) {
if (!transientByTempDir[tempDir]) {
transientByTempDir[tempDir] = [];
}
transientByTempDir[tempDir].push(item.pathname);
}
break;
}
}
});
// Close the tabs first
await dispatch(_closeTabs({ tabUids }));
// Delete transient files after tabs are closed
for (const [tempDir, filePaths] of Object.entries(transientByTempDir)) {
try {
const results = await ipcRenderer.invoke('renderer:delete-transient-requests', filePaths, tempDir);
if (results.errors?.length > 0) {
console.error('Errors deleting transient files:', results.errors);
}
} catch (err) {
console.error('Failed to delete transient request files:', err);
}
}
};

View File

@@ -2868,6 +2868,7 @@ export const collectionsSlice = createSlice({
const prevEphemerals = (existingEnv.variables || []).filter((v) => v.ephemeral);
existingEnv.name = environment.name;
existingEnv.variables = environment.variables;
existingEnv.color = environment.color;
/*
Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves.
*/

View File

@@ -18,12 +18,13 @@ export const globalEnvironmentsSlice = createSlice({
state.activeGlobalEnvironmentUid = action.payload?.activeGlobalEnvironmentUid;
},
_addGlobalEnvironment: (state, action) => {
const { name, uid, variables = [] } = action.payload;
const { name, uid, variables = [], color } = action.payload;
if (name?.length) {
state.globalEnvironments.push({
uid,
name,
variables
variables,
color
});
}
},
@@ -110,7 +111,7 @@ const getWorkspaceContext = (state) => {
return { workspaceUid, workspacePath: workspace?.pathname };
};
export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, getState) => {
export const addGlobalEnvironment = ({ name, variables = [], color }) => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const uid = uuid();
const environment = { name, uid, variables };
@@ -120,12 +121,13 @@ export const addGlobalEnvironment = ({ name, variables = [] }) => (dispatch, get
environmentSchema
.validate(environment)
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables, workspaceUid, workspacePath }))
.then(() => ipcRenderer.invoke('renderer:create-global-environment', { name, uid, variables, color, workspaceUid, workspacePath }))
.then((result) => {
const finalUid = result?.uid || uid;
const finalName = result?.name || name;
const finalVariables = result?.variables || variables;
dispatch(_addGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables }));
const finalColor = result?.color || color;
dispatch(_addGlobalEnvironment({ name: finalName, uid: finalUid, variables: finalVariables, color: finalColor }));
return finalUid;
})
.then((finalUid) => dispatch(selectGlobalEnvironment({ environmentUid: finalUid })))
@@ -181,6 +183,13 @@ export const renameGlobalEnvironment = ({ name: newName, environmentUid }) => (d
.invoke('renderer:get-global-environments', { workspaceUid, workspacePath })
.then((data) => {
dispatch(updateGlobalEnvironments(data));
if (resolvedUid !== environmentUid) {
const currentState = getState();
const draft = currentState.globalEnvironments.globalEnvironmentDraft;
if (draft?.environmentUid === environmentUid) {
dispatch(setGlobalEnvironmentDraft({ environmentUid: resolvedUid, variables: draft.variables }));
}
}
return resolvedUid;
});
})

View File

@@ -26,7 +26,9 @@ export const tabsSlice = createSlice({
'collection-runner',
'environment-settings',
'global-environment-settings',
'preferences'
'preferences',
'workspaceOverview',
'workspaceEnvironments'
];
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
@@ -94,7 +96,11 @@ export const tabsSlice = createSlice({
state.activeTabUid = uid;
},
focusTab: (state, action) => {
state.activeTabUid = action.payload.uid;
const { uid } = action.payload;
const tabExists = state.tabs.some((t) => t.uid === uid);
if (tabExists) {
state.activeTabUid = uid;
}
},
switchTab: (state, action) => {
if (!state.tabs || !state.tabs.length) {
@@ -169,8 +175,10 @@ export const tabsSlice = createSlice({
const activeTab = find(state.tabs, (t) => t.uid === state.activeTabUid);
const tabUids = action.payload.tabUids || [];
// remove the tabs from the state
state.tabs = filter(state.tabs, (t) => !tabUids.includes(t.uid));
const nonClosableTypes = ['workspaceOverview', 'workspaceEnvironments'];
state.tabs = filter(state.tabs, (t) =>
!tabUids.includes(t.uid) || nonClosableTypes.includes(t.type)
);
if (activeTab && state.tabs.length) {
const { collectionUid } = activeTab;
@@ -197,9 +205,14 @@ export const tabsSlice = createSlice({
}
},
closeAllCollectionTabs: (state, action) => {
const collectionUid = action.payload.collectionUid;
const { collectionUid } = action.payload;
const prevActiveTabUid = state.activeTabUid;
state.tabs = filter(state.tabs, (t) => t.collectionUid !== collectionUid);
state.activeTabUid = null;
const activeTabStillExists = state.tabs.some((t) => t.uid === prevActiveTabUid);
if (!activeTabStillExists) {
state.activeTabUid = state.tabs.length > 0 ? last(state.tabs).uid : null;
}
},
makeTabPermanent: (state, action) => {
const { uid } = action.payload;

View File

@@ -1,199 +0,0 @@
import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter';
import find from 'lodash/find';
import last from 'lodash/last';
const initialState = {
tabs: [],
activeTabUid: null
};
export const workspaceTabsSlice = createSlice({
name: 'workspaceTabs',
initialState,
reducers: {
addWorkspaceTab: (state, action) => {
const { uid, workspaceUid, type, label, permanent = false } = action.payload;
const existingTab = find(state.tabs, (tab) => tab.uid === uid);
if (existingTab) {
state.activeTabUid = existingTab.uid;
return;
}
// Check if a tab of the same type already exists for this workspace
const existingTypeTab = find(
state.tabs,
(tab) => tab.workspaceUid === workspaceUid && tab.type === type
);
if (existingTypeTab) {
state.activeTabUid = existingTypeTab.uid;
return;
}
state.tabs.push({
uid,
workspaceUid,
type,
label,
permanent
});
state.activeTabUid = uid;
},
focusWorkspaceTab: (state, action) => {
state.activeTabUid = action.payload.uid;
},
closeWorkspaceTab: (state, action) => {
const tabUid = action.payload.uid;
const tab = find(state.tabs, (t) => t.uid === tabUid);
// Don't allow closing permanent tabs
if (tab?.permanent) {
return;
}
state.tabs = filter(state.tabs, (t) => t.uid !== tabUid);
// If we closed the active tab, activate another one
if (state.activeTabUid === tabUid && state.tabs.length > 0) {
state.activeTabUid = last(state.tabs).uid;
} else if (state.tabs.length === 0) {
state.activeTabUid = null;
}
},
closeWorkspaceTabs: (state, action) => {
const tabUids = action.payload.tabUids || [];
// Filter out permanent tabs from the close request
const tabsToClose = tabUids.filter((uid) => {
const tab = find(state.tabs, (t) => t.uid === uid);
return tab && !tab.permanent;
});
state.tabs = filter(state.tabs, (t) => !tabsToClose.includes(t.uid));
// If active tab was closed, activate another one
if (tabsToClose.includes(state.activeTabUid)) {
if (state.tabs.length > 0) {
state.activeTabUid = last(state.tabs).uid;
} else {
state.activeTabUid = null;
}
}
},
closeAllWorkspaceTabs: (state, action) => {
const workspaceUid = action.payload?.workspaceUid;
if (workspaceUid) {
// Close non-permanent tabs for specific workspace
state.tabs = filter(
state.tabs,
(t) => t.workspaceUid !== workspaceUid || t.permanent
);
} else {
// Close all non-permanent tabs
state.tabs = filter(state.tabs, (t) => t.permanent);
}
// If active tab was closed, activate another one
const activeTabExists = find(state.tabs, (t) => t.uid === state.activeTabUid);
if (!activeTabExists) {
state.activeTabUid = state.tabs.length > 0 ? last(state.tabs).uid : null;
}
},
reorderWorkspaceTabs: (state, action) => {
const { sourceUid, targetUid } = action.payload;
const tabs = state.tabs;
const sourceIdx = tabs.findIndex((t) => t.uid === sourceUid);
const targetIdx = tabs.findIndex((t) => t.uid === targetUid);
// Don't reorder permanent tabs
const sourceTab = tabs[sourceIdx];
if (sourceTab?.permanent) {
return;
}
if (sourceIdx < 0 || targetIdx < 0 || sourceIdx === targetIdx) {
return;
}
const [moved] = tabs.splice(sourceIdx, 1);
tabs.splice(targetIdx, 0, moved);
state.tabs = tabs;
},
initializeWorkspaceTabs: (state, action) => {
const { workspaceUid, permanentTabs } = action.payload;
// Check if permanent tabs already exist for this workspace
const existingPermanentTabs = state.tabs.filter(
(t) => t.workspaceUid === workspaceUid && t.permanent
);
if (existingPermanentTabs.length === 0) {
// Add permanent tabs
permanentTabs.forEach((tab) => {
state.tabs.push({
uid: `${workspaceUid}-${tab.type}`,
workspaceUid,
type: tab.type,
label: tab.label,
permanent: true
});
});
}
const workspaceActiveTab = state.tabs.find(
(t) => t.uid === state.activeTabUid && t.workspaceUid === workspaceUid
);
if (!workspaceActiveTab) {
const workspaceTabs = state.tabs.filter((t) => t.workspaceUid === workspaceUid);
if (workspaceTabs.length > 0) {
state.activeTabUid = workspaceTabs[0].uid;
}
}
},
setActiveWorkspaceTab: (state, action) => {
const { workspaceUid, type } = action.payload;
let tab = find(
state.tabs,
(t) => t.workspaceUid === workspaceUid && t.type === type
);
if (!tab) {
const newTabUid = `${workspaceUid}-${type}`;
const labels = {
overview: 'Overview',
environments: 'Global Environments',
preferences: 'Preferences'
};
const newTab = {
uid: newTabUid,
workspaceUid,
type,
label: labels[type] || type,
permanent: false
};
state.tabs.push(newTab);
tab = newTab;
}
state.activeTabUid = tab.uid;
}
}
});
export const {
addWorkspaceTab,
focusWorkspaceTab,
closeWorkspaceTab,
closeWorkspaceTabs,
closeAllWorkspaceTabs,
reorderWorkspaceTabs,
initializeWorkspaceTabs,
setActiveWorkspaceTab
} = workspaceTabsSlice.actions;
export default workspaceTabsSlice.reducer;

View File

@@ -6,13 +6,14 @@ import {
updateWorkspace,
addCollectionToWorkspace,
removeCollectionFromWorkspace,
updateWorkspaceLoadingState
updateWorkspaceLoadingState,
setWorkspaceScratchCollection
} from '../workspaces';
import { showHomePage } from '../app';
import { createCollection, openCollection, openMultipleCollections } from '../collections/actions';
import { removeCollection } from '../collections';
import { createCollection, openCollection, openMultipleCollections, openScratchCollectionEvent } from '../collections/actions';
import { removeCollection, addTransientDirectory, updateCollectionMountStatus } from '../collections';
import { updateGlobalEnvironments } from '../global-environments';
import { initializeWorkspaceTabs, setActiveWorkspaceTab } from '../workspaceTabs';
import { addTab, focusTab } from '../tabs';
import { normalizePath } from 'utils/common/path';
import toast from 'react-hot-toast';
@@ -262,15 +263,29 @@ export const switchWorkspace = (workspaceUid) => {
dispatch(updateGlobalEnvironments({ globalEnvironments: [], activeGlobalEnvironmentUid: null }));
}
const scratchCollection = await dispatch(mountScratchCollection(workspaceUid));
await loadWorkspaceCollectionsForSwitch(dispatch, workspace);
dispatch(showHomePage());
const permanentTabs = [
{ type: 'overview', label: 'Overview' },
{ type: 'environments', label: 'Global Environments' }
];
dispatch(initializeWorkspaceTabs({ workspaceUid, permanentTabs }));
dispatch(setActiveWorkspaceTab({ workspaceUid, type: 'overview' }));
if (scratchCollection?.uid) {
const overviewTabUid = `${scratchCollection.uid}-overview`;
const environmentsTabUid = `${scratchCollection.uid}-environments`;
dispatch(addTab({
uid: overviewTabUid,
collectionUid: scratchCollection.uid,
type: 'workspaceOverview'
}));
dispatch(addTab({
uid: environmentsTabUid,
collectionUid: scratchCollection.uid,
type: 'workspaceEnvironments'
}));
dispatch(focusTab({
uid: overviewTabUid
}));
}
};
};
@@ -840,3 +855,88 @@ export const deleteWorkspaceDotEnvFile = (workspaceUid, filename = '.env') => (d
.catch(reject);
});
};
// Scratch Collection Actions
/**
* Get the scratch collection for a workspace
*/
export const getScratchCollection = (workspaceUid) => {
return (dispatch, getState) => {
const state = getState();
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
if (!workspace?.scratchCollectionUid) {
return null;
}
return state.collections.collections.find((c) => c.uid === workspace.scratchCollectionUid);
};
};
/**
* Mount scratch collection for a workspace
*/
export const mountScratchCollection = (workspaceUid) => {
return async (dispatch, getState) => {
const state = getState();
const workspace = state.workspaces.workspaces.find((w) => w.uid === workspaceUid);
if (!workspace) {
return null;
}
if (workspace.scratchCollectionUid) {
const existingCollection = state.collections.collections.find(
(c) => c.uid === workspace.scratchCollectionUid
);
if (existingCollection) {
return existingCollection;
}
}
try {
const tempDirectoryPath = await ipcRenderer.invoke('renderer:mount-workspace-scratch', {
workspaceUid,
workspacePath: workspace.pathname || 'default'
});
const { generateUidBasedOnHash } = await import('utils/common');
const scratchCollectionUid = generateUidBasedOnHash(tempDirectoryPath);
const brunoConfig = {
opencollection: '1.0.0',
name: 'Scratch',
type: 'collection',
ignore: ['node_modules', '.git']
};
await ipcRenderer.invoke('renderer:add-collection-watcher', {
collectionPath: tempDirectoryPath,
collectionUid: scratchCollectionUid,
brunoConfig
});
await dispatch(openScratchCollectionEvent(scratchCollectionUid, tempDirectoryPath, brunoConfig));
dispatch(setWorkspaceScratchCollection({
workspaceUid,
scratchCollectionUid,
scratchTempDirectory: tempDirectoryPath
}));
dispatch(addTransientDirectory({
collectionUid: scratchCollectionUid,
pathname: tempDirectoryPath
}));
dispatch(updateCollectionMountStatus({ collectionUid: scratchCollectionUid, mountStatus: 'mounted' }));
return { uid: scratchCollectionUid, pathname: tempDirectoryPath };
} catch (error) {
console.error('Error mounting scratch collection:', error);
if (workspace.scratchCollectionUid) {
dispatch(updateCollectionMountStatus({ collectionUid: workspace.scratchCollectionUid, mountStatus: 'unmounted' }));
}
return null;
}
};
};

View File

@@ -116,6 +116,16 @@ export const workspacesSlice = createSlice({
workspace.dotEnvVariables = mainEnvFile?.variables || [];
workspace.dotEnvExists = mainEnvFile?.exists || false;
}
},
// Set scratch collection info on workspace
setWorkspaceScratchCollection: (state, action) => {
const { workspaceUid, scratchCollectionUid, scratchTempDirectory } = action.payload;
const workspace = state.workspaces.find((w) => w.uid === workspaceUid);
if (workspace) {
workspace.scratchCollectionUid = scratchCollectionUid;
workspace.scratchTempDirectory = scratchTempDirectory;
}
}
}
});
@@ -129,7 +139,8 @@ export const {
removeCollectionFromWorkspace,
updateWorkspaceLoadingState,
workspaceDotEnvUpdateEvent,
setWorkspaceDotEnvVariables
setWorkspaceDotEnvVariables,
setWorkspaceScratchCollection
} = workspacesSlice.actions;
export default workspacesSlice.reducer;

View File

@@ -398,14 +398,14 @@ const lightPastelTheme = {
},
codemirror: {
bg: 'transparent',
bg: colors.BACKGROUND,
border: colors.WHITE,
placeholder: {
color: colors.GRAY_6,
opacity: 0.75
},
gutter: {
bg: 'transparent'
bg: colors.BACKGROUND
},
variable: {
valid: colors.GREEN,

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